DEV Community

Cover image for Adding Korean Stocks to Your Multi-Market App with Infoway API
Infoway API
Infoway API

Posted on

Adding Korean Stocks to Your Multi-Market App with Infoway API

You've already got US or Japanese equities working. Your users are asking for Samsung Electronics. How much of the existing code do you actually need to rewrite?

Less than you think — and in a few places, Korean market data is simpler to work with than what you're probably used to. This is a practical migration guide covering exactly what changes and what stays the same when you extend an existing multi-market app to include KOSPI and KOSDAQ data via Infoway API.


The Short Answer

Infoway's Korean endpoints follow exactly the same patterns as the other markets — the REST call structure, the WebSocket protocol, the authentication header, the response shapes. If you already have Japan or Hong Kong code working, the integration layer for Korea is almost copy-paste with the domain prefix swapped. The logic-level changes are where the actual work is.

Here's the full scope of what changes:

Layer Changes for Korea
REST URL prefix /japan//korea/
WebSocket business param japankorea
Price units JPY / HKD / USD → KRW (large numbers, commas needed)
Daily price limit ±30% (wider than most Asian markets)
Timezone KST = UTC+9, no DST, no lunch break
Settlement T+2 (same as US, different from some Asian markets)
Kline endpoint style GET (Japan) → POST with JSON body (Korea)

Everything else — response schemas, WebSocket protocol codes, pagination, heartbeat behavior — is identical.


Setting Up: Same Auth, New Prefix

If you're already registered with Infoway, your existing API key works for Korean data out of the box. No additional registration or plan upgrade is needed. The free trial covers all Korean data types: trades, best bid/ask, candles, and financial statements.

# Nothing changes here
HEADERS = {"apiKey": "YOUR_INFOWAY_API_KEY"}
Enter fullscreen mode Exit fullscreen mode

Korean stock symbols are six-digit numbers followed by .KS:

SYMBOLS = {
    "005930.KS": "Samsung Electronics",
    "000660.KS": "SK Hynix",
    "035420.KS": "NAVER",
    "005380.KS": "Hyundai Motor",
    "035720.KS": "Kakao",
}
Enter fullscreen mode Exit fullscreen mode

Both KOSPI (main board) and KOSDAQ (growth board) use the .KS suffix. Unlike Japan where you use .JP, there's no board-specific suffix variation to handle.


REST Endpoints

The URL structure is a direct parallel. Here's how it maps for the three core endpoints:

Data type Japan Korea
Latest trades GET /japan/batch_trade/{codes} GET /korea/batch_trade/{codes}
Best bid/ask GET /japan/batch_depth/{codes} GET /korea/batch_depth/{codes}
Candles GET /japan/v2/batch_kline/{type}/{num}/{codes} POST /korea/v2/batch_kline (JSON body)

Trades and depth work exactly the same way — swap the prefix and you're done:

import requests

BASE = "https://data.infoway.io"
HEADERS = {"apiKey": "YOUR_API_KEY"}

# Japan trade (existing code)
def get_japan_trade(codes: str) -> list:
    return requests.get(f"{BASE}/japan/batch_trade/{codes}", headers=HEADERS).json()["data"]

# Korea trade (one word changed)
def get_korea_trade(codes: str) -> list:
    return requests.get(f"{BASE}/korea/batch_trade/{codes}", headers=HEADERS).json()["data"]
Enter fullscreen mode Exit fullscreen mode

The response schema is also identical — same field names (s, t, p, v, vw, td), same types.

The one real difference is the candle endpoint. Japan uses a GET with the parameters in the URL; Korea uses a POST with a JSON body. This is the only structural change in the REST layer:

# Japan candles: GET with path parameters
def get_japan_kline(codes: str, kline_type: int = 8, num: int = 10) -> list:
    url = f"{BASE}/japan/v2/batch_kline/{kline_type}/{num}/{codes}"
    return requests.get(url, headers=HEADERS).json()["data"]

# Korea candles: POST with JSON body
def get_korea_kline(codes: str, kline_type: int = 8, num: int = 10,
                    timestamp: int | None = None) -> list:
    payload: dict = {"klineType": kline_type, "klineNum": num, "codes": codes}
    if timestamp:
        payload["timestamp"] = timestamp
    return requests.post(
        f"{BASE}/korea/v2/batch_kline",
        json=payload,
        headers={**HEADERS, "Content-Type": "application/json"}
    ).json()["data"]
Enter fullscreen mode Exit fullscreen mode

Both return the same candle schema: t, o, h, l, c, v, vw, pc, pca — so downstream parsing code needs no changes once you've swapped the fetch function.

The same batch limits apply: single-symbol requests support up to 500 bars; multi-symbol requests (up to 100 symbols in one call) are capped at 2 bars per symbol.


WebSocket: One Parameter Changes

The WebSocket endpoint is structurally identical. The only thing that changes is the business query parameter:

# Japan stream
WS_JAPAN = "wss://data.infoway.io/ws?business=japan&apikey={key}"

# Korea stream
WS_KOREA = "wss://data.infoway.io/ws?business=korea&apikey={key}"
Enter fullscreen mode Exit fullscreen mode

The protocol codes, message structure, subscription format, heartbeat behavior, and reconnection semantics are all the same:

Action Code
Subscribe trades 10000
Subscribe depth 10003
Subscribe candles 10006
Heartbeat (send every 30s) 10010
Trade push 10002
Depth push 10005
Candle push 10008

If you've abstracted your WebSocket client behind an interface, extending to Korea may literally be a single parameter change:

class MarketStreamClient:
    MARKETS = {
        "japan": "wss://data.infoway.io/ws?business=japan&apikey={key}",
        "korea": "wss://data.infoway.io/ws?business=korea&apikey={key}",
        "hk":    "wss://data.infoway.io/ws?business=hk&apikey={key}",
    }

    def __init__(self, market: str, api_key: str):
        self.url = self.MARKETS[market].format(key=api_key)
        # rest of implementation unchanged
Enter fullscreen mode Exit fullscreen mode

What Actually Changes in Your Business Logic

This is where the real work lives. The mechanics of the Korean market differ from Japan and the US in ways that affect your application logic — not the API integration layer.

1. Timezone: Lock it to UTC+9, No DST, No Lunch Break

KST is always UTC+9. Korea abolished daylight saving time in 1988 and has never reinstated it. Your timezone handling should be a hardcoded fixed offset or the Asia/Seoul zone identifier — never a conditional that adjusts for daylight saving:

from zoneinfo import ZoneInfo
from datetime import datetime, time

KST = ZoneInfo("Asia/Seoul")

def ts_to_kst(ts_ms: int) -> datetime:
    return datetime.fromtimestamp(ts_ms / 1000, tz=KST)

SESSIONS = {
    "pre_market":   (time(8, 30),  time(9, 0)),
    "continuous":   (time(9, 0),   time(15, 20)),
    "closing_auction": (time(15, 20), time(15, 30)),
    "after_hours":  (time(15, 40), time(18, 0)),
}

def get_session(ts_ms: int) -> str:
    t = ts_to_kst(ts_ms).time()
    for name, (start, end) in SESSIONS.items():
        if start <= t < end:
            return name
    return "closed"
Enter fullscreen mode Exit fullscreen mode

Unlike Japan, there is no lunch break. This simplifies intraday candle processing considerably — you won't see the mid-day gap in 1-minute and 5-minute candles that requires special handling in Japanese data.

2. Price Limits: ±30%, Not ±10%

The KRX daily price limit is ±30% from the previous close. If your anomaly detection or circuit breaker logic references a ±10% threshold (appropriate for China A-shares) or LULD bands (US), it needs updating for Korea:

# Unified limit checker across markets
DAILY_LIMITS = {
    "us":    None,    # LULD exists but no hard cap
    "japan": 0.25,    # varies by price tier; rough upper bound
    "hk":    None,    # no daily limit
    "korea": 0.30,    # hard ±30% limit for all KRX stocks
    "cn":    0.10,    # ±10% main board, ±20% STAR/ChiNext
}

def is_at_limit(market: str, pct_change: float) -> bool:
    limit = DAILY_LIMITS.get(market)
    if limit is None:
        return False
    return abs(pct_change) >= limit * 0.99   # 1% buffer for float precision
Enter fullscreen mode Exit fullscreen mode

In practice, this matters most for KOSDAQ biotech and small-cap names. When a stock is locked at its upper limit (상한가), you'll see a stream of trades printing at the same price with no upward movement. This is normal and should not be treated as stale or frozen data.

3. Price Scale: KRW Is Large

Korean won prices are numerically large by default. Samsung Electronics trades around ₩80,000 per share; smaller-cap stocks often trade in single or double-digit thousands. Make sure your display formatting uses number separators:

def format_krw(price: str | float) -> str:
    return f"{float(price):,.0f}"

# ₩80,400  — readable
# ₩80400   — avoid
Enter fullscreen mode Exit fullscreen mode

There's no subdivision issue (no "cents") — KRW prices are always integers in practice, though the API returns them as strings to preserve precision.

4. Multi-Market Snapshot: Combining Korea with Other Markets

If your app shows a unified dashboard across markets, Korea fits naturally into the existing polling pattern. The only extra care needed is session alignment — Korea and Japan are both UTC+9 with no DST, but Korea has no lunch break while Japan closes 11:30–12:30 (JST):

import requests
from datetime import datetime
from zoneinfo import ZoneInfo

HEADERS = {"apiKey": "YOUR_API_KEY"}
BASE    = "https://data.infoway.io"
KST     = ZoneInfo("Asia/Seoul")
JST     = ZoneInfo("Asia/Tokyo")

def market_is_open(market: str) -> bool:
    from datetime import time
    if market == "korea":
        now = datetime.now(tz=KST)
        t   = now.time()
        return now.weekday() < 5 and time(9, 0) <= t < time(15, 30)
    if market == "japan":
        now = datetime.now(tz=JST)
        t   = now.time()
        return now.weekday() < 5 and (time(9, 0) <= t < time(11, 30) or time(12, 30) <= t < time(15, 30))
    return False   # extend for other markets


def get_snapshot(market: str, codes: str) -> list:
    resp = requests.get(f"{BASE}/{market}/batch_trade/{codes}", headers=HEADERS)
    return resp.json().get("data", [])


# Pull snapshots from all open markets
WATCHLIST = {
    "korea": "005930.KS,000660.KS",
    "japan": "7203.JP,6758.JP",
}

for market, codes in WATCHLIST.items():
    if not market_is_open(market):
        print(f"[{market.upper()}] market closed")
        continue
    for item in get_snapshot(market, codes):
        print(f"[{market.upper()}] {item['s']:14s}  price={item['p']}")
Enter fullscreen mode Exit fullscreen mode

The Feature You Don't Get Elsewhere: Financial Statements

Korean data on Infoway comes with something the other markets don't include as part of the base offering: structured financial statements. Income statement, balance sheet, cash flow, valuation ratios (P/E, P/B, ROE), dividend history, and EPS actuals vs. analyst consensus — all via REST.

This is worth highlighting because it changes what's possible in a Korean-specific feature. For Japan, building a fundamental screen requires either a separate data vendor or web scraping. For Korea, it's available on the same API key, no extra integration needed.

def get_valuation(symbol: str) -> dict:
    resp = requests.get(
        f"{BASE}/common/basic/financial/statistics",
        params={"symbol": symbol, "type": "STOCK_KR", "period_type": "fq"},
        headers=HEADERS
    ).json()
    metrics = {item["itemId"]: item.get("currentValue") or item.get("itemValue")
               for item in resp.get("data", [])}
    return {
        "pe":  metrics.get("price_earnings"),
        "pb":  metrics.get("price_book"),
        "roe": metrics.get("roe"),
    }

# Combine real-time price with live valuation
for sym in ["005930.KS", "000660.KS", "035420.KS"]:
    trade = get_snapshot("korea", sym)[0]
    val   = get_valuation(sym)
    print(
        f"{sym}  price=₩{float(trade['p']):,.0f}"
        f"  P/E={val['pe']:.1f}x  P/B={val['pb']:.2f}x  ROE={val['roe']:.1f}%"
    )
Enter fullscreen mode Exit fullscreen mode

If you're building a fundamental-driven feature layer on top of price data, Korea is currently the easiest market to cover with a single Infoway API key.


Putting It All Together

Here's a self-contained script that polls Korea and Japan simultaneously, shows real-time prices with session labels, and applies market-appropriate limit detection:

import time
import requests
from datetime import datetime, time as dtime
from zoneinfo import ZoneInfo

API_KEY = "YOUR_API_KEY"
BASE    = "https://data.infoway.io"
HEADERS = {"apiKey": API_KEY}

KST = ZoneInfo("Asia/Seoul")
JST = ZoneInfo("Asia/Tokyo")

MARKETS = {
    "korea": {
        "codes":  "005930.KS,000660.KS,035420.KS",
        "tz":     KST,
        "limit":  0.30,
        "open":   lambda t: dtime(9, 0) <= t < dtime(15, 30),
        "currency": "",
    },
    "japan": {
        "codes":  "7203.JP,6758.JP,9984.JP",
        "tz":     JST,
        "limit":  0.25,
        "open":   lambda t: dtime(9, 0) <= t < dtime(11, 30) or dtime(12, 30) <= t < dtime(15, 30),
        "currency": "¥",
    },
}


def is_open(cfg: dict) -> bool:
    now = datetime.now(tz=cfg["tz"])
    return now.weekday() < 5 and cfg["open"](now.time())


def fetch_trades(market: str, codes: str) -> list:
    try:
        resp = requests.get(f"{BASE}/{market}/batch_trade/{codes}",
                            headers=HEADERS, timeout=8)
        resp.raise_for_status()
        return resp.json().get("data", [])
    except Exception as e:
        print(f"  [{market}] fetch error: {e}")
        return []


def poll_once():
    print(f"\n{'='*60}")
    print(f"Snapshot at {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC")
    print(f"{'='*60}")

    for market, cfg in MARKETS.items():
        label = market.upper()
        if not is_open(cfg):
            print(f"[{label}] closed")
            continue

        trades = fetch_trades(market, cfg["codes"])
        for t in trades:
            price = float(t["p"])
            sym   = t["s"]
            curr  = cfg["currency"]
            direction = {0: " ", 1: "", 2: ""}.get(t.get("td", 0), " ")
            print(f"[{label}] {sym:14s} {curr}{price:>10,.0f}  {direction}")


if __name__ == "__main__":
    while True:
        poll_once()
        time.sleep(10)
Enter fullscreen mode Exit fullscreen mode

Frequently Asked Questions

How do I handle the pre-market auction data if I only want continuous-session prices?

Filter by the t (timestamp) field in the response. Convert to KST and discard anything with a time before 09:00. The session checker functions in this guide do exactly that.

Does Korea have a market-wide circuit breaker similar to the US's Level 1/2/3 halts?

Yes. KRX has an index-level circuit breaker (KOSPI and KOSDAQ separately) that triggers at −8%, −15%, and −20% intraday declines — at the third level, trading halts for the rest of the day. Individual stock trading halts for unusual price or volume movements also exist. During a market-wide halt, the WebSocket connection stays alive but no trade pushes arrive; you'd see the silence alongside the index move, which is the signal.

Is there a symbol list for all KOSPI and KOSDAQ listings?

Yes, log into the Infoway dashboard and download the Korea symbol list as an Excel file. It includes all listed symbols, company names. You can also query it programmatically via the symbol list endpoint in the API.

My Japan code uses pytz for timezone handling. Should I migrate to zoneinfo for Korea?

Either works — pytz.timezone("Asia/Seoul") and ZoneInfo("Asia/Seoul") both correctly represent KST with no DST. zoneinfo is in the standard library from Python 3.9+ and is the more modern choice, but there's no functional difference for KST since the zone has been DST-free since 1988.

Top comments (0)