웹소켓 연결 완료
This commit is contained in:
@@ -17,6 +17,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import ssl
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
@@ -26,10 +27,11 @@ except ImportError:
|
||||
|
||||
try:
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.exceptions import ConnectionClosed, InvalidStatusCode
|
||||
except ImportError:
|
||||
websockets = None # type: ignore
|
||||
ConnectionClosed = Exception # type: ignore
|
||||
InvalidStatusCode = Exception # type: ignore
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import setex_json, TTL_TICK, TTL_QUOTE
|
||||
@@ -66,8 +68,16 @@ def _get_lock() -> asyncio.Lock:
|
||||
return _approval_key_lock
|
||||
|
||||
|
||||
def _create_ssl_context() -> ssl.SSLContext:
|
||||
"""KIS WebSocket 연결용 SSL 컨텍스트 (검증 비활성화)."""
|
||||
ctx = ssl.create_default_context()
|
||||
# ctx.check_hostname = False
|
||||
# ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def _get_ws_url() -> str:
|
||||
return _KIS_WS_VIRTUAL if getattr(settings, "is_virtual_trading", True) else _KIS_WS_REAL
|
||||
return _KIS_WS_VIRTUAL if getattr(settings, "KIS_MODE", True) == "virtual" else _KIS_WS_REAL
|
||||
|
||||
|
||||
async def _fetch_approval_key() -> Optional[str]:
|
||||
@@ -100,17 +110,24 @@ async def _fetch_approval_key() -> Optional[str]:
|
||||
logger.error("❌ httpx 패키지 미설치. pip install httpx")
|
||||
return None
|
||||
|
||||
base_url = settings.KOREA_INVESTMENT_API_BASE_URL
|
||||
app_key = settings.KOREA_INVESTMENT_APP_KEY
|
||||
app_secret = settings.KOREA_INVESTMENT_APP_SECRET
|
||||
# KIS_MODE에 따라 REST API 엔드포인트 선택 (WebSocket 승인도 같은 서버)
|
||||
is_real = getattr(settings, "KIS_MODE", "virtual").lower() == "real"
|
||||
if is_real:
|
||||
base_url = "https://openapi.koreainvestment.com:9443"
|
||||
app_key = settings.KIS_REAL_APP_KEY or settings.KOREA_INVESTMENT_APP_KEY
|
||||
app_secret = settings.KIS_REAL_APP_SECRET or settings.KOREA_INVESTMENT_APP_SECRET
|
||||
else:
|
||||
base_url = "https://openapivts.koreainvestment.com:29443"
|
||||
app_key = settings.KIS_VIRTUAL_APP_KEY or settings.KOREA_INVESTMENT_APP_KEY
|
||||
app_secret = settings.KIS_VIRTUAL_APP_SECRET or settings.KOREA_INVESTMENT_APP_SECRET
|
||||
|
||||
if not app_key or not app_secret:
|
||||
logger.error("❌ KIS_APP_KEY / KIS_APP_SECRET 미설정. approval_key 발급 불가.")
|
||||
return None
|
||||
|
||||
logger.info(f"🔑 KIS approval_key 자동 발급 요청: {base_url}/oauth2/Approval")
|
||||
logger.info(f"🔑 KIS approval_key 자동 발급 요청: {base_url}/oauth2/Approval (모드={['virtual','real'][is_real]})")
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with _httpx.AsyncClient(timeout=30.0, verify=False) as client:
|
||||
resp = await client.post(
|
||||
f"{base_url}/oauth2/Approval",
|
||||
json={
|
||||
@@ -127,7 +144,7 @@ async def _fetch_approval_key() -> Optional[str]:
|
||||
if key:
|
||||
_cached_approval_key = key
|
||||
_cached_key_expires = time.monotonic() + _APPROVAL_KEY_TTL
|
||||
logger.info("✅ KIS WS approval_key 발급 완료 (유효: 23h)")
|
||||
logger.info(f"✅ KIS WS approval_key 발급 완료 (유효: 23h, 길이={len(key)})")
|
||||
return _cached_approval_key
|
||||
logger.error(f"❌ approval_key 응답에 키 없음: {data}")
|
||||
else:
|
||||
@@ -316,6 +333,11 @@ class KisRealtimeStream:
|
||||
|
||||
1단계 → 2단계 → 3단계 → 4단계 반복.
|
||||
연결 끊김/오류 시 approval_key 재발급 후 재연결.
|
||||
|
||||
주의: KIS WebSocket이 응답하지 않으면 일부 설정을 확인하십시오:
|
||||
- .env: KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL (포트, 경로)
|
||||
- KIS 모드: KIS_MODE (virtual/real)
|
||||
- 재시도: 최대 5회 후 경고만 표시 (무한 루프 방지)
|
||||
"""
|
||||
if websockets is None:
|
||||
logger.error("❌ websockets 패키지 미설치. pip install websockets")
|
||||
@@ -324,6 +346,9 @@ class KisRealtimeStream:
|
||||
self._running = True
|
||||
ws_url = _get_ws_url()
|
||||
logger.info(f"🔌 KIS 실시간 스트림 시작 (URL: {ws_url})")
|
||||
|
||||
connection_attempts = 0
|
||||
max_connection_attempts = 5 # 5회 실패 후 경고만
|
||||
|
||||
while self._running:
|
||||
# ── 1단계: approval_key 발급 ──────────────────────────────────
|
||||
@@ -334,55 +359,140 @@ class KisRealtimeStream:
|
||||
|
||||
# ── 2단계: WebSocket 연결 ────────────────────────────────────
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
ping_interval=30,
|
||||
ping_timeout=10,
|
||||
close_timeout=5,
|
||||
) as ws:
|
||||
self._ws = ws
|
||||
logger.info("✅ KIS WebSocket 연결 완료")
|
||||
ssl_context = _create_ssl_context()
|
||||
|
||||
if connection_attempts < max_connection_attempts:
|
||||
logger.info(f"🚀 KIS WS 접속 시도 #{connection_attempts + 1}: {ws_url} (key_len={len(self._approval_key) if self._approval_key else 0})")
|
||||
else:
|
||||
if connection_attempts == max_connection_attempts:
|
||||
logger.warning(
|
||||
f"⚠️ KIS WS {max_connection_attempts}회 연결 실패. 앞으로 30초마다 시도합니다.\n"
|
||||
f" 확인 사항:\n"
|
||||
f" 1. .env에서 KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL 정확한가?\n"
|
||||
f" 2. KIS_MODE가 'virtual' 또는 'real'로 설정되었나?\n"
|
||||
f" 3. 네트워크에서 {ws_url}에 접근 가능한가?\n"
|
||||
f" 4. KIS 서버가 WebSocket을 지원하나? (REST API만 지원할 수도 있음)\n"
|
||||
)
|
||||
logger.debug(f"🔄 KIS WS 재연결 시도 #{connection_attempts + 1}...")
|
||||
|
||||
# KIS WebSocket 연결: approval_key는 JSON 메시지에서 전송
|
||||
# WebSocket 연결 시에는 URL에 key 포함하지 않음
|
||||
logger.debug(f"📡 WebSocket 연결 URL: {ws_url}/websocket/realtime")
|
||||
|
||||
# WebSocket 헤더 설정
|
||||
ws_full_url = ws_url if ws_url.endswith("/websocket/realtime") else f"{ws_url.rstrip('/')}/websocket/realtime"
|
||||
# Allow optional override via settings, otherwise derive an Origin from the ws url
|
||||
origin = getattr(settings, "KIS_WS_ORIGIN", None)
|
||||
if not origin:
|
||||
try:
|
||||
if ws_full_url.startswith("wss://"):
|
||||
origin = "https://" + ws_full_url[len("wss://"):].split("/")[0]
|
||||
elif ws_full_url.startswith("ws://"):
|
||||
origin = "http://" + ws_full_url[len("ws://"):].split("/")[0]
|
||||
except Exception:
|
||||
origin = None
|
||||
|
||||
# ── 3단계: 초기 구독 ──────────────────────────────────
|
||||
await self._sync_subscriptions(ws)
|
||||
last_sync = time.monotonic()
|
||||
# 헤더에 Origin 반드시 포함
|
||||
extra_headers = {
|
||||
"Origin": "https://securities.koreainvestment.com",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
}
|
||||
if origin:
|
||||
extra_headers["Origin"] = origin
|
||||
logger.debug(f"📡 WebSocket extra headers: {extra_headers}")
|
||||
try:
|
||||
# Optional subprotocols can be set via settings.KIS_WS_SUBPROTOCOLS
|
||||
subprotocols = getattr(settings, "KIS_WS_SUBPROTOCOLS", None)
|
||||
if isinstance(subprotocols, str):
|
||||
subprotocols = [s.strip() for s in subprotocols.split(",") if s.strip()]
|
||||
if subprotocols:
|
||||
logger.info(f"🧩 Attempting WebSocket subprotocols: {subprotocols}")
|
||||
|
||||
# ── 4단계: 데이터 수신 루프 ──────────────────────────
|
||||
async for raw_msg in ws:
|
||||
# 주기적 구독 동기화
|
||||
if time.monotonic() - last_sync >= _SYNC_INTERVAL:
|
||||
await self._sync_subscriptions(ws)
|
||||
last_sync = time.monotonic()
|
||||
ws_connect_kwargs = dict(
|
||||
ping_interval=30,
|
||||
ping_timeout=10,
|
||||
close_timeout=5,
|
||||
#ssl=ssl_context,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
if subprotocols:
|
||||
ws_connect_kwargs["subprotocols"] = subprotocols
|
||||
|
||||
parsed = _parse_realtime_message(raw_msg)
|
||||
if not parsed:
|
||||
continue
|
||||
async with websockets.connect(ws_full_url, **ws_connect_kwargs) as ws:
|
||||
self._ws = ws
|
||||
connection_attempts = 0 # Reset on success
|
||||
logger.info("✅ KIS WebSocket 연결 완료")
|
||||
|
||||
msg_type = parsed.get("_type")
|
||||
# ── 3단계: 초기 구독 ──────────────────────────────────
|
||||
await self._sync_subscriptions(ws)
|
||||
last_sync = time.monotonic()
|
||||
|
||||
# PINGPONG → pong 응답
|
||||
if msg_type == "pingpong":
|
||||
await ws.send(raw_msg)
|
||||
continue
|
||||
# ── 4단계: 데이터 수신 루프 ──────────────────────────
|
||||
async for raw_msg in ws:
|
||||
# 주기적 구독 동기화
|
||||
if time.monotonic() - last_sync >= _SYNC_INTERVAL:
|
||||
await self._sync_subscriptions(ws)
|
||||
last_sync = time.monotonic()
|
||||
|
||||
# ACK 로그 (구독 응답 등)
|
||||
if msg_type == "json":
|
||||
hdr = parsed.get("data", {}).get("header", {})
|
||||
if hdr.get("tr_id"):
|
||||
logger.debug(f"[KIS ACK] tr_id={hdr['tr_id']} "
|
||||
f"rt_cd={parsed['data'].get('body', {}).get('rt_cd')}")
|
||||
continue
|
||||
parsed = _parse_realtime_message(raw_msg)
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
# 체결/호가 → Redis 캐시 + 클라이언트 브로드캐스트
|
||||
if msg_type in ("tick", "quote"):
|
||||
stock_code = parsed.get("stock_code", "")
|
||||
if stock_code:
|
||||
# 1) Redis SETEX 캐시 (실패해도 non-blocking)
|
||||
await _cache_realtime_data(msg_type, stock_code, parsed)
|
||||
# 2) WebSocket 클라이언트 브로드캐스트
|
||||
await websocket_manager.send_market_update(stock_code, parsed)
|
||||
msg_type = parsed.get("_type")
|
||||
|
||||
# PINGPONG → pong 응답
|
||||
if msg_type == "pingpong":
|
||||
await ws.send(raw_msg)
|
||||
continue
|
||||
|
||||
# ACK 로그 (구독 응답 등)
|
||||
if msg_type == "json":
|
||||
hdr = parsed.get("data", {}).get("header", {})
|
||||
if hdr.get("tr_id"):
|
||||
logger.debug(f"[KIS ACK] tr_id={hdr['tr_id']} "
|
||||
f"rt_cd={parsed['data'].get('body', {}).get('rt_cd')}")
|
||||
continue
|
||||
|
||||
# 체결/호가 → Redis 캐시 + 클라이언트 브로드캐스트
|
||||
if msg_type in ("tick", "quote"):
|
||||
stock_code = parsed.get("stock_code", "")
|
||||
if stock_code:
|
||||
# 1) Redis SETEX 캐시 (실패해도 non-blocking)
|
||||
await _cache_realtime_data(msg_type, stock_code, parsed)
|
||||
# 2) WebSocket 클라이언트 브로드캐스트
|
||||
await websocket_manager.send_market_update(stock_code, parsed)
|
||||
except InvalidStatusCode as isc:
|
||||
# WebSocket handshake returned a non-101 HTTP response (often 200)
|
||||
connection_attempts += 1
|
||||
status = getattr(isc, 'status_code', None)
|
||||
logger.error(f"❌ KIS WS InvalidStatusCode: HTTP {status}")
|
||||
# Try HTTP GET diagnostic (replace wss:// -> https://)
|
||||
if _httpx is not None:
|
||||
try:
|
||||
http_url = ws_full_url.replace('wss://', 'https://')
|
||||
logger.info(f"🔍 Performing HTTP GET diagnostic to {http_url}")
|
||||
async with _httpx.AsyncClient(timeout=10.0, verify=False) as client:
|
||||
# include Origin in diagnostic GET if available to mirror websocket handshake
|
||||
diag_headers = {"User-Agent": "Mozilla/5.0"}
|
||||
if origin:
|
||||
diag_headers["Origin"] = origin
|
||||
resp = await client.get(http_url, headers=diag_headers)
|
||||
headers_snippet = dict(list(resp.headers.items())[:10])
|
||||
body_snippet = (resp.text or '')[:1000]
|
||||
logger.error(f"🔍 Diagnostic HTTP {resp.status_code} headers={headers_snippet} body={body_snippet}")
|
||||
except Exception as diag_exc:
|
||||
logger.error(f"🔍 Diagnostic HTTP GET failed: {diag_exc}", exc_info=True)
|
||||
else:
|
||||
logger.error("🔍 httpx not installed — cannot perform HTTP diagnostic")
|
||||
# reset ws state and approval_key to attempt re-issue on next loop
|
||||
self._ws = None
|
||||
self._subscribed_codes.clear()
|
||||
self._approval_key = None
|
||||
# continue to retry after outer sleep
|
||||
continue
|
||||
|
||||
except ConnectionClosed as e:
|
||||
connection_attempts += 1
|
||||
logger.warning(f"⚡ KIS WS 연결 끊김: {e}. {_RECONNECT_WAIT}초 후 재연결...")
|
||||
self._ws = None
|
||||
self._subscribed_codes.clear()
|
||||
@@ -394,7 +504,10 @@ class KisRealtimeStream:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ KIS WS 오류: {e}", exc_info=True)
|
||||
connection_attempts += 1
|
||||
logger.error(f"❌ KIS WS 오류 (시도 #{connection_attempts}): {type(e).__name__}: {e}")
|
||||
if connection_attempts <= max_connection_attempts:
|
||||
logger.debug(f" 예외 상세: ", exc_info=True)
|
||||
self._ws = None
|
||||
self._subscribed_codes.clear()
|
||||
self._approval_key = None
|
||||
|
||||
Reference in New Issue
Block a user