""" KIS WebSocket 실시간 시세 수신 서비스 4단계 흐름: 1단계: REST API(/oauth2/Approval) 호출 → approval_key 발급 2단계: KIS WebSocket 서버(실전/모의) 연결 3단계: H0STCNT0(체결가), H0STASP0(호가) 구독 메시지 전송 4단계: 서버 데이터 파싱 → Redis SETEX 캐시 (tick:{code}, quote:{code}) → websocket_manager.send_market_update() 브로드캐스트 - websocket_manager 구독 목록 변화 감지 → 동적 구독/해제 - 연결 끊김 시 자동 재연결 (approval_key 재발급 포함) - lifespan()에서 asyncio.create_task()로 기동 """ import asyncio import contextlib import json import logging import time import ssl from typing import Optional try: import httpx as _httpx except ImportError: _httpx = None # type: ignore try: import websockets from websockets.exceptions import ConnectionClosed except ImportError: websockets = None # type: ignore ConnectionClosed = Exception # type: ignore # [L-9] 핸드셰이크 비-101 응답 예외: legacy(<14) InvalidStatusCode / 신 asyncio 클라이언트(>=14) InvalidStatus _handshake_excs: list = [] try: from websockets.exceptions import InvalidStatus as _InvalidStatus # websockets >= 10 _handshake_excs.append(_InvalidStatus) except ImportError: pass # harness: allow-except-pass 구버전 websockets 에는 InvalidStatus 가 없다(아래 InvalidStatusCode 사용) InvalidStatusCode = None # type: ignore _WS_MAJOR_AT_IMPORT = ( int(str(websockets.__version__).split(".")[0]) if websockets is not None and str(getattr(websockets, "__version__", "")).split(".")[0].isdigit() else 0 ) if websockets is not None and _WS_MAJOR_AT_IMPORT < 14: # legacy 클라이언트(<14)만 InvalidStatusCode 를 던진다. 14+ 에서 import 하면 DeprecationWarning. try: from websockets.exceptions import InvalidStatusCode # type: ignore[no-redef] _handshake_excs.append(InvalidStatusCode) except ImportError: InvalidStatusCode = None # type: ignore _HANDSHAKE_STATUS_ERRORS: tuple = tuple(_handshake_excs) or (Exception,) from app.core.config import settings from app.core.json_helpers import _dumps from app.core.log_throttle import warn_throttled from app.core.redis import setex_json, TTL_TICK, TTL_QUOTE from app.services.websocket_manager import websocket_manager logger = logging.getLogger(__name__) def _websockets_major_version() -> int: try: return int(str(getattr(websockets, "__version__", "0")).split(".")[0]) except (TypeError, ValueError): return 0 def _ws_header_kwarg_name() -> str: """[L-9] websockets 14+ 의 기본 connect(신 asyncio 클라이언트)는 `additional_headers`, 그 이전(legacy)은 `extra_headers` 를 받는다. requirements 12.0 / 로컬 15.x 혼재 대응.""" return "additional_headers" if _websockets_major_version() >= 14 else "extra_headers" def _handshake_status_code(exc: BaseException): """InvalidStatusCode.status_code(legacy) / InvalidStatus.response.status_code(신) 양쪽 지원.""" status = getattr(exc, "status_code", None) if status is None: status = getattr(getattr(exc, "response", None), "status_code", None) return status # ── KIS WebSocket URL (실전/모의 분기, .env KIS_WS_REAL_URL / KIS_WS_VIRTUAL_URL 오버라이드 가능) ── _KIS_WS_REAL = settings.KIS_WS_REAL_URL _KIS_WS_VIRTUAL = settings.KIS_WS_VIRTUAL_URL # ── 실시간 TR_ID ───────────────────────────────────────────────────────────── TR_TICK = "H0STCNT0" # 주식 현재가 (체결 틱) TR_QUOTE = "H0STASP0" # 주식 호가 # ── 타이밍 상수 ────────────────────────────────────────────────────────────── # (.env 설정: KIS_SYNC_INTERVAL, KIS_RECONNECT_WAIT, KIS_APPROVAL_KEY_TTL) _SYNC_INTERVAL = settings.KIS_SYNC_INTERVAL # 구독 목록 동기화 주기 (초) _RECONNECT_WAIT = settings.KIS_RECONNECT_WAIT # 재연결 기본 대기 (초) _RECONNECT_MAX_WAIT = 300 # 최대 재연결 대기 (5분) — 지수 백오프 상한 _APPROVAL_KEY_TTL = settings.KIS_APPROVAL_KEY_TTL # approval_key 캐시 유효시간 (초) # [H-4] KIS 실시간 등록 한도: 1세션당 41건(체결·호가 각각 1건씩 차감). _MAX_REGISTRATIONS = int(getattr(settings, "KIS_WS_MAX_REGISTRATIONS", 41)) # 구독 실패 ACK(rt_cd≠0) 후 같은 (TR, 종목) 재시도까지 대기(초) — 영구 실패의 재전송 폭주 방지 _ACK_FAIL_RETRY_SEC = 60.0 # [L-6] 실시간 데이터 처리 타임아웃(초) — Redis/클라이언트 전송이 KIS 수신을 막지 않게 _REDIS_WRITE_TIMEOUT = 2.0 # 레코드당 필드 수 (다건 프레임 분할용, KIS 개발가이드) _RECORD_FIELD_COUNT = {"H0STCNT0": 46} # ── 모듈 레벨 approval_key 캐시 ────────────────────────────────────────────── _STABLE_SESSION_SECS = 60.0 # 이 이상 유지된 세션만 '성공'으로 보고 백오프/streak 리셋 _NORMAL_CLOSE_REISSUE_STREAK = 3 # 서버 정상 close 연속 N회면 캐시 키를 폐기하고 재발급 # 안정 세션(>=_STABLE_SESSION_SECS) 뒤 첫 끊김의 재연결 대기(초). KIS 서버가 매시 정각에 # close frame 없이 연결을 끊는 정시 정리(추정)마다 KIS_RECONNECT_WAIT(30초) 공백이 나던 것을 줄인다. _STABLE_RECONNECT_WAIT = 1.0 _cached_approval_key: Optional[str] = None _cached_key_expires: float = 0.0 # 연결 건전성 통계 (/health 노출용: get_ws_health_v2) _ws_stats = { "disconnects": 0, "top_of_hour_disconnects": 0, # 정각 직후(분 0~2) 끊김 "last_disconnect_ts": None, # epoch seconds "last_tick_ts": None, # epoch seconds } def _record_disconnect_v2(now_ts: Optional[float] = None) -> None: """끊김 1건 기록. 정각 직후(분 0~2)면 top_of_hour 로도 센다.""" ts = time.time() if now_ts is None else now_ts _ws_stats["disconnects"] += 1 _ws_stats["last_disconnect_ts"] = ts if time.gmtime(ts).tm_min <= 2: # KST=UTC+9 → 분 단위는 동일 _ws_stats["top_of_hour_disconnects"] += 1 def get_ws_health_v2() -> dict: """정시 끊김 횟수·마지막 tick 수신 시각(epoch s)·경과초. main.py /health 에서 합쳐 쓸 수 있다.""" last = _ws_stats["last_tick_ts"] out = dict(_ws_stats) out["last_tick_age_sec"] = None if last is None else round(max(0.0, time.time() - last), 1) return out def _reconnect_backoff_v2(connection_attempts: int, session_secs: float) -> float: """재연결 대기. 안정 세션 뒤 첫 끊김은 즉시 재연결, 그 외는 기존 지수 백오프.""" if session_secs >= _STABLE_SESSION_SECS: return _STABLE_RECONNECT_WAIT return min(_RECONNECT_WAIT * (2 ** min(max(connection_attempts, 1) - 1, 6)), _RECONNECT_MAX_WAIT) _approval_key_lock: Optional[asyncio.Lock] = None def _get_lock() -> asyncio.Lock: """이벤트 루프 생성 후 호출되므로 lazy init.""" global _approval_key_lock if _approval_key_lock is None: _approval_key_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: # [이슈4 수정] default값이 True이면 True == "virtual" → False → 실전 URL 반환 오류. # KIS_MODE 기본값은 "virtual" 문자열로 지정해야 함. # 대소문자 무관: 'real' 만 실전 URL. 'Virtual'/'VIRTUAL' 이 실전 WS 로 가는 것을 막는다(안전한 쪽=모의). mode = str(getattr(settings, "KIS_MODE", "virtual") or "virtual").strip().lower() return _KIS_WS_REAL if mode == "real" else _KIS_WS_VIRTUAL def _invalidate_cached_approval_key() -> None: """모듈 캐시의 approval_key 를 만료 처리 → 다음 _fetch_approval_key 가 재발급한다.""" global _cached_key_expires _cached_key_expires = 0.0 async def _fetch_approval_key() -> Optional[str]: """1단계: KIS REST /oauth2/Approval 호출로 WebSocket 접속키 발급. 우선순위: 1) settings.KIS_WS_APPROVAL_KEY 수동 설정값 2) 캐시된 키 (만료 전) 3) REST API 자동 발급 """ global _cached_approval_key, _cached_key_expires async with _get_lock(): # 1순위: 수동 설정 키 manual_key = getattr(settings, "KIS_WS_APPROVAL_KEY", "") or \ getattr(settings, "KOREA_INVESTMENT_WS_APPROVAL_KEY", "") if manual_key: if _cached_approval_key != manual_key: logger.info("🔑 KIS WS approval_key: 수동 설정값 사용") _cached_approval_key = manual_key _cached_key_expires = time.monotonic() + _APPROVAL_KEY_TTL return _cached_approval_key # 2순위: 캐시 유효 if _cached_approval_key and time.monotonic() < _cached_key_expires: return _cached_approval_key # 3순위: REST API 자동 발급 if _httpx is None: logger.error("❌ httpx 패키지 미설치. pip install httpx") return None # 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 (모드={['virtual','real'][is_real]})") try: async with _httpx.AsyncClient(timeout=30.0, verify=True) as client: resp = await client.post( f"{base_url}/oauth2/Approval", json={ "grant_type": "client_credentials", "appkey": app_key, "secretkey": app_secret, }, headers={"content-type": "application/json; charset=utf-8"}, ) if resp.status_code == 200: data = resp.json() key = data.get("approval_key") if key: _cached_approval_key = key _cached_key_expires = time.monotonic() + _APPROVAL_KEY_TTL logger.info(f"✅ KIS WS approval_key 발급 완료 (유효: 23h, 길이={len(key)})") return _cached_approval_key logger.error(f"❌ approval_key 응답에 키 없음: {data}") else: logger.error( f"❌ approval_key 발급 실패: HTTP {resp.status_code} — {resp.text[:200]}" ) except Exception as exc: logger.error(f"❌ approval_key 발급 중 오류: {exc}", exc_info=True) return None # ── 구독 메시지 빌더 ────────────────────────────────────────────────────────── def _build_subscribe_msg(approval_key: str, tr_id: str, stock_code: str, tr_type: str = "1") -> str: """KIS WebSocket 구독/해제 메시지 생성. tr_type='1': 구독, tr_type='2': 해제 """ return _dumps({ "header": { "approval_key": approval_key, "custtype": "P", "tr_type": tr_type, "content-type": "utf-8", }, "body": { "input": { "tr_id": tr_id, "tr_key": stock_code, } }, }) def _should_alert_instant_close(total: int) -> bool: """즉시-close 연속 횟수가 streak 에 처음 도달할 때, 이후 streak*4 배수마다 경보 (스팸 방지).""" n = _NORMAL_CLOSE_REISSUE_STREAK return total == n or (total > n and total % (n * 4) == 0) async def _alert_instant_close_v2(total: int) -> None: try: from app.services.telegram_notifier import telegram_notifier await asyncio.wait_for( telegram_notifier.notify_system_warning( "KIS_WS", f"실시간 WS 접속 직후 정상 close {total}회 연속 (다른 세션/서버 거부 의심)"), timeout=10.0) except Exception as e: logger.warning(f"[KIS WS] 텔레그램 경보 전송 실패: {e}") # ── 수신 데이터 파서 ───────────────────────────────────────────────────────── def _parse_realtime_message(raw: str) -> Optional[dict]: """KIS 실시간 메시지 파싱. - 체결/호가 데이터: 파이프(|) 구분 텍스트 - 응답/핑: JSON Returns: dict 또는 None """ if not raw: return None # JSON 응답 (ACK / PINGPONG) if raw.startswith("{"): try: j = json.loads(raw) if j.get("header", {}).get("tr_id") == "PINGPONG": return {"_type": "pingpong"} return {"_type": "json", "data": j} except Exception: return None # 텍스트 파이프 포맷: 암호화여부|TR_ID|데이터건수|데이터 parts = raw.split("|") if len(parts) < 4: return None _enc, tr_id, _cnt, body = parts[0], parts[1], parts[2], parts[3] # 다건 프레임(_cnt>1)은 레코드가 '^' 로 이어 붙어 온다. 단건 API 는 마지막(최신)만 반환. records = _split_records(tr_id, _cnt, body) return _build_record(tr_id, records[-1] if records else []) def _parse_realtime_records_v2(raw: str) -> list: """다건 프레임의 모든 레코드를 파싱한다 (oldest → newest). 프레임 내 종목이 섞여 있어도 앞 종목 tick 이 버려지지 않도록, 호출부가 (type, stock_code)별 마지막 것을 고른다. JSON/PINGPONG 은 단건 리스트로 반환. """ if not raw: return [] if raw.startswith("{"): one = _parse_realtime_message(raw) return [one] if one else [] parts = raw.split("|") if len(parts) < 4: return [] tr_id, cnt, body = parts[1], parts[2], parts[3] out = [] for fields in _split_records(tr_id, cnt, body): rec = _build_record(tr_id, fields) if rec: out.append(rec) return out def _build_record(tr_id: str, fields: list) -> Optional[dict]: if tr_id == TR_TICK and len(fields) >= 15: # H0STCNT0 필드 순서 (KIS 개발가이드 기준) # [0]=종목코드 [1]=체결시간 [2]=현재가 [3]=전일대비부호 [4]=전일대비(원) [5]=전일대비율(%) # [6]=가중평균가 [7]=시가 [8]=고가 [9]=저가 [10]=매도호가1 [11]=매수호가1 # [12]=체결거래량 [13]=누적거래량 [14]=누적거래대금 # (2026-09-18 수정: 기존 [4]/[7]/[9] 는 전일대비원/시가/저가를 잘못 읽고 있었음) return { "_type": "tick", "tr_id": tr_id, "stock_code": fields[0], "price": _safe_float(fields[2]), "change_rate": _safe_float(fields[5]), # 당일 시가/고가/저가 — 차트 당일봉 합성이 Redis tick 만으로 가능하도록(M-7) "open": _safe_float(fields[7]), "high": _safe_float(fields[8]), "low": _safe_float(fields[9]), "volume": _safe_int(fields[13]), "trade_amount":_safe_int(fields[14]), "timestamp": fields[1] if len(fields) > 1 else "", } if tr_id == TR_QUOTE and len(fields) >= 38: # H0STASP0 실제 포맷 (KIS 개발가이드 기준): # [0]=종목코드, [1]=영업시간, [2]=시간구분코드 # [3~12]=매도호가1~10, [13~22]=매수호가1~10 # [23~32]=매도호가잔량1~10, [33~42]=매수호가잔량1~10 def _f(i: int): return _safe_float(fields[i]) if len(fields) > i else None def _i(i: int): return _safe_int(fields[i]) if len(fields) > i else None return { "_type": "quote", "tr_id": tr_id, "stock_code": fields[0], "ask1": _f(3), "ask2": _f(4), "ask3": _f(5), "ask4": _f(6), "ask5": _f(7), "bid1": _f(13), "bid2": _f(14), "bid3": _f(15), "bid4": _f(16), "bid5": _f(17), "ask_qty1": _i(23), "ask_qty2": _i(24), "ask_qty3": _i(25), "ask_qty4": _i(26), "ask_qty5": _i(27), "bid_qty1": _i(33), "bid_qty2": _i(34), "bid_qty3": _i(35), "bid_qty4": _i(36), "bid_qty5": _i(37), } return None def _split_records(tr_id: str, cnt_raw: str, body: str) -> list: """파이프 포맷 본문을 레코드 리스트로 분할 (oldest → newest). KIS 는 체결이 몰리면 한 프레임에 N건(데이터건수 필드)을 '^' 로 이어 보낸다. 레코드당 필드 수: H0STCNT0 = 46(KIS 개발가이드). 그 외 TR 은 전체 필드 수 / 건수가 나누어떨어질 때만 분할하고, 판정 불가면 기존 동작(전체를 1건으로 취급)으로 폴백한다. """ fields = body.split("^") try: cnt = int(cnt_raw) except (TypeError, ValueError): cnt = 1 if cnt <= 1: return [fields] expected = _RECORD_FIELD_COUNT.get(tr_id) if expected and len(fields) >= expected * cnt: per = expected elif len(fields) % cnt == 0: per = len(fields) // cnt else: warn_throttled( logger, "kis_ws:split_records", "[KIS WS] 다건 프레임 분할 불가 tr_id=%s cnt=%s fields=%d — 첫 레코드로 폴백", tr_id, cnt, len(fields), ) return [fields] return [fields[i * per:(i + 1) * per] for i in range(cnt)] def _safe_float(v: str) -> Optional[float]: try: return float(v) except (ValueError, TypeError): return None def _safe_int(v: str) -> Optional[int]: try: return int(v) except (ValueError, TypeError): return None # ── Redis 캐시 헬퍼 ────────────────────────────────────────────────────────── async def _cache_realtime_data(msg_type: str, stock_code: str, data: dict) -> None: """실시간 데이터를 Redis에 SETEX로 캐시. 키 스키마: tick:{code} - 최신 체결가/거래량 (TTL: TTL_TICK 초) quote:{code} - 최신 매도/매수 호가 5단계 (TTL: TTL_QUOTE 초) Redis 미연결 시 setex_json()이 조용히 False 반환 → 서비스 영향 없음. """ if msg_type == "tick": await setex_json(f"tick:{stock_code}", TTL_TICK, data) elif msg_type == "quote": await setex_json(f"quote:{stock_code}", TTL_QUOTE, data) # ── 구독 대상 계산 (순수 함수) ─────────────────────────────────────────────── def _is_domestic_code(code: str) -> bool: """KIS 국내 실시간(H0STCNT0/H0STASP0) 대상 종목코드인지. 국내 단축코드는 6자리 영숫자이고 숫자를 반드시 포함한다(신규 코드 예: 0004V0). 미국 티커(AAPL, BRK.B 등)는 숫자가 없거나 길이가 달라 제외된다. """ c = (code or "").strip() return len(c) == 6 and c.isascii() and c.isalnum() and any(ch.isdigit() for ch in c) def _build_target_registrations(subscribed, selected, budget: int) -> tuple: """[H-4] (목표 등록 리스트, 한도 초과로 제외된 리스트) 반환. 등록 = (tr_id, 종목코드). 우선순위: 선택 종목 체결 > 선택 종목 호가 > 그 외 구독(보유 종목 등) 체결. 호가(H0STASP0)는 선택 종목(차트를 보고 있는 종목)에만 등록한다. 미국 티커는 제외. """ selected_dom = sorted({c for c in (selected or ()) if _is_domestic_code(c)}) sel_set = set(selected_dom) others = sorted({c for c in (subscribed or ()) if _is_domestic_code(c)} - sel_set) wanted = ( [(TR_TICK, c) for c in selected_dom] + [(TR_QUOTE, c) for c in selected_dom] + [(TR_TICK, c) for c in others] ) budget = max(0, int(budget)) return wanted[:budget], wanted[budget:] # ── 스트림 클래스 ──────────────────────────────────────────────────────────── class KisRealtimeStream: """KIS WebSocket 실시간 스트림 관리 클래스. 사용 흐름: run() 진입 → 1단계: _fetch_approval_key() REST 호출 → 2단계: websockets.connect() WebSocket 연결 → 3단계: _sync_subscriptions() 초기 구독 + _sync_loop() 별도 태스크(H-5) → 4단계: async for raw_msg in ws: 수신 → 파싱 → _enqueue_realtime() → _dispatch_loop() 가 Redis SETEX + 클라이언트 전송 (수신 루프와 분리, L-6) """ def __init__(self): # 체결(H0STCNT0) 등록된 종목 — 호환용 뷰. 진실 소스는 _registered. self._subscribed_codes: set[str] = set() self._registered: set[tuple[str, str]] = set() self._ack_fail_until: dict[tuple[str, str], float] = {} self._last_dropped: tuple = () self._ws = None self._running = False self._approval_key: Optional[str] = None self._sync_event: asyncio.Event = asyncio.Event() # 종목·종류별 최신 데이터 (코얼레싱) — 수신 루프는 넣기만 하고 즉시 다음 프레임을 읽는다 self._latest: dict[tuple[str, str], dict] = {} self._dispatch_event: asyncio.Event = asyncio.Event() def notify_subscription_change(self): """구독 목록 변경 시 즉시 KIS 동기화 트리거 (subscribe/unsubscribe/set_subscriptions 직후)""" self._sync_event.set() def _refresh_code_view(self) -> None: self._subscribed_codes = {c for tr, c in self._registered if tr == TR_TICK} def _reset_connection_state(self) -> None: """[H-3] 연결 종료(정상 close 포함) 시 반드시 호출 — 새 세션은 등록 0건에서 시작한다.""" self._ws = None self._registered.clear() self._subscribed_codes.clear() self._ack_fail_until.clear() self._last_dropped = () async def _ensure_approval_key(self) -> bool: """approval_key 확보. 없으면 REST API 호출.""" if not self._approval_key or time.monotonic() >= _cached_key_expires: self._approval_key = await _fetch_approval_key() if not self._approval_key: logger.warning("⚠️ approval_key 없음. 실시간 구독 불가.") return False return True async def _send_registration(self, ws, tr_id: str, code: str, tr_type: str) -> None: msg = _build_subscribe_msg(self._approval_key, tr_id, code, tr_type) await ws.send(msg) await asyncio.sleep(0.05) # rate-limit 방지 async def _send_subscribe(self, ws, code: str, tr_type: str = "1"): """단일 종목 구독/해제 메시지 전송 (체결 + 호가). (호환 유지 — 동기화는 _send_registration 사용)""" if not self._approval_key: return for tr_id in (TR_TICK, TR_QUOTE): await self._send_registration(ws, tr_id, code, tr_type) async def _sync_subscriptions(self, ws): """websocket_manager 구독 목록과 KIS 등록 상태를 동기화. [H-4] 해지를 추가보다 먼저 보내 한도 슬롯을 확보하고, 세션 한도(_MAX_REGISTRATIONS)를 넘으면 우선순위대로 자른다. 구독 실패 ACK 로 백오프 중인 등록은 건너뛴다. """ if not self._approval_key: return subscribed = websocket_manager.get_all_subscribed_codes() selected = websocket_manager.get_selected_codes_v2() targets, dropped = _build_target_registrations(subscribed, selected, _MAX_REGISTRATIONS) if dropped and tuple(dropped) != self._last_dropped: logger.warning( f"⚠️ KIS 실시간 등록 한도 {_MAX_REGISTRATIONS}건 초과 — {len(dropped)}건 제외: " f"{[f'{tr}:{c}' for tr, c in dropped[:10]]}" ) self._last_dropped = tuple(dropped) now = time.monotonic() for k in [k for k, until in self._ack_fail_until.items() if until <= now]: self._ack_fail_until.pop(k, None) target_set = set(targets) to_remove = sorted(r for r in self._registered if r not in target_set) to_add = [r for r in targets if r not in self._registered and r not in self._ack_fail_until] for tr_id, code in to_remove: logger.info(f"🚫 KIS 구독 해제: {tr_id} {code}") await self._send_registration(ws, tr_id, code, "2") self._registered.discard((tr_id, code)) for tr_id, code in to_add: if len(self._registered) >= _MAX_REGISTRATIONS: break logger.info(f"📡 KIS 구독 추가: {tr_id} {code}") await self._send_registration(ws, tr_id, code, "1") self._registered.add((tr_id, code)) self._refresh_code_view() async def _sync_loop(self, ws) -> None: """[H-5] 구독 동기화 전용 태스크 — 수신 프레임이 없어도 변경 즉시/주기적으로 동기화.""" while True: try: await asyncio.wait_for(self._sync_event.wait(), timeout=_SYNC_INTERVAL) except asyncio.TimeoutError: pass # harness: allow-except-pass 주기 동기화 타이머 만료 — 정상 경로 self._sync_event.clear() try: await self._sync_subscriptions(ws) except ConnectionClosed: return except Exception as e: logger.warning(f"⚠️ KIS 구독 동기화 실패: {type(e).__name__}: {e}") def _handle_ack(self, data: dict) -> None: """구독/해지 ACK 처리. [H-4] rt_cd≠0 이면 warning + 해당 등록을 빼서 재시도 가능하게.""" hdr = data.get("header") or {} body = data.get("body") or {} tr_id = hdr.get("tr_id") if not tr_id: return tr_key = hdr.get("tr_key") rt_cd = str(body.get("rt_cd", "")).strip() msg1 = str(body.get("msg1", "") or "") msg_up = msg1.upper() if rt_cd == "0" or "ALREADY IN SUBSCRIBE" in msg_up: logger.debug(f"[KIS ACK] tr_id={tr_id} tr_key={tr_key} rt_cd={rt_cd} {msg1}") return if "UNSUBSCRIBE" in msg_up: logger.warning(f"⚠️ [KIS ACK] 해지 실패 tr_id={tr_id} tr_key={tr_key} " f"rt_cd={rt_cd} msg_cd={body.get('msg_cd')} {msg1}") return key = (tr_id, tr_key) if tr_key and key in self._registered: self._registered.discard(key) self._ack_fail_until[key] = time.monotonic() + _ACK_FAIL_RETRY_SEC self._refresh_code_view() logger.warning(f"⚠️ [KIS ACK] 구독 실패 tr_id={tr_id} tr_key={tr_key} rt_cd={rt_cd} " f"msg_cd={body.get('msg_cd')} {msg1} — {_ACK_FAIL_RETRY_SEC:.0f}초 후 재시도") # ── 실시간 데이터 분배 (L-6) ────────────────────────────────────────────── def _enqueue_realtime(self, msg_type: str, stock_code: str, parsed: dict) -> None: self._latest[(msg_type, stock_code)] = parsed _ws_stats["last_tick_ts"] = time.time() self._dispatch_event.set() async def _dispatch_one(self, msg_type: str, stock_code: str, parsed: dict) -> None: try: await asyncio.wait_for( _cache_realtime_data(msg_type, stock_code, parsed), timeout=_REDIS_WRITE_TIMEOUT ) except asyncio.TimeoutError: warn_throttled(logger, "kis_ws:redis_timeout", "[KIS WS] Redis 캐시 %.1fs 초과 (%s %s)", _REDIS_WRITE_TIMEOUT, msg_type, stock_code) except Exception as e: warn_throttled(logger, "kis_ws:redis_error", "[KIS WS] Redis 캐시 실패 (%s %s): %s", msg_type, stock_code, e) try: await websocket_manager.send_market_update(stock_code, parsed) except Exception as e: warn_throttled(logger, "kis_ws:send_error", "[KIS WS] 클라이언트 전송 실패 (%s): %s", stock_code, e) async def _dispatch_once(self) -> int: batch, self._latest = self._latest, {} if batch: await asyncio.gather( *(self._dispatch_one(mt, code, p) for (mt, code), p in batch.items()) ) return len(batch) async def _dispatch_loop(self) -> None: while True: await self._dispatch_event.wait() self._dispatch_event.clear() await self._dispatch_once() async def _on_raw_message(self, ws, raw_msg) -> None: if isinstance(raw_msg, (bytes, bytearray)): raw_msg = raw_msg.decode("utf-8", "replace") if not isinstance(raw_msg, str) or raw_msg.startswith("{"): parsed_list = [_parse_realtime_message(raw_msg)] else: parsed_list = _parse_realtime_records_v2(raw_msg) # (type, code)별 마지막(최신) 레코드만 enqueue — 종목이 섞인 프레임에서도 tick 보존 latest: dict = {} for parsed in parsed_list: if not parsed: continue msg_type = parsed.get("_type") if msg_type in ("tick", "quote"): code = parsed.get("stock_code", "") if code: latest[(msg_type, code)] = parsed continue await self._handle_non_data_v2(ws, raw_msg, parsed) for (mt, code), parsed in latest.items(): self._enqueue_realtime(mt, code, parsed) async def _handle_non_data_v2(self, ws, raw_msg, parsed) -> None: msg_type = parsed.get("_type") if msg_type == "pingpong": await ws.send(raw_msg) return if msg_type == "json": self._handle_ack(parsed.get("data") or {}) return async def _run_session(self, ws) -> None: """연결 1회분: 초기 구독 → 동기화 태스크 기동 → 수신 루프. 반환 = 서버가 정상 close.""" self._ws = ws await self._sync_subscriptions(ws) sync_task = asyncio.create_task(self._sync_loop(ws)) try: async for raw_msg in ws: await self._on_raw_message(ws, raw_msg) finally: sync_task.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await sync_task async def run(self): """메인 스트림 루프 (재연결 포함). 1단계 → 2단계 → 3단계 → 4단계 반복. 연결 끊김/오류/정상 close 시 등록 상태를 비우고(H-3) approval_key 재발급 후 재연결. """ if websockets is None: logger.error("❌ websockets 패키지 미설치. pip install websockets") return self._running = True ws_url = _get_ws_url() logger.info(f"🔌 KIS 실시간 스트림 시작 (URL: {ws_url})") connection_attempts = 0 normal_close_streak = 0 instant_close_total = 0 max_connection_attempts = 5 # 5회 실패 후 경고만 dispatch_task = asyncio.create_task(self._dispatch_loop()) try: while self._running: # ── 1단계: approval_key 발급 ────────────────────────────────── if not await self._ensure_approval_key(): logger.warning(f"⏳ approval_key 미확보. {_RECONNECT_WAIT}초 후 재시도...") await asyncio.sleep(_RECONNECT_WAIT) continue # ── 2단계: WebSocket 연결 ──────────────────────────────────── _session_secs = 0.0 try: ws_full_url = ws_url if connection_attempts < max_connection_attempts: logger.info(f"🚀 KIS WS 접속 시도 #{connection_attempts + 1}: {ws_full_url} " f"(key_len={len(self._approval_key) if self._approval_key else 0})") elif connection_attempts == max_connection_attempts: logger.warning( f"⚠️ KIS WS {max_connection_attempts}회 연결 실패. 백오프하며 계속 시도합니다.\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_full_url}에 접근 가능한가?\n" ) origin = getattr(settings, "KIS_WS_ORIGIN", None) if not origin: 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] extra_headers = { "Origin": origin or "https://securities.koreainvestment.com", "User-Agent": "Mozilla/5.0", } 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}") ws_connect_kwargs = dict(ping_interval=30, ping_timeout=10, close_timeout=5) # [L-9] websockets 버전별 헤더 인자명 ws_connect_kwargs[_ws_header_kwarg_name()] = extra_headers if subprotocols: ws_connect_kwargs["subprotocols"] = subprotocols try: async with websockets.connect(ws_full_url, **ws_connect_kwargs) as ws: # 연결 성공만으로 attempts 를 리셋하지 않는다 — 접속 직후 close 가 # 반복되면 백오프가 5초에 고정돼 approval 재발급이 폭주한다. logger.info("✅ KIS WebSocket 연결 완료") _session_t0 = time.monotonic() try: await self._run_session(ws) finally: _session_secs = time.monotonic() - _session_t0 if _session_secs >= _STABLE_SESSION_SECS: normal_close_streak = 0 # 충분히 유지된 세션 → 연속 즉시-close 아님 instant_close_total = 0 # [H-3] 서버가 정상 close(ConnectionClosedOK) → async for 가 예외 없이 끝남 connection_attempts += 1 normal_close_streak += 1 logger.warning("⚡ KIS WS 서버가 연결을 정상 종료(close) — 재연결합니다.") _record_disconnect_v2() if _session_secs < _STABLE_SESSION_SECS: self._approval_key = None if _session_secs < _STABLE_SESSION_SECS: instant_close_total += 1 if normal_close_streak >= _NORMAL_CLOSE_REISSUE_STREAK: # 재발급은 streak 도달 시 1회만 — 이후 streak 를 되돌려 매 close 마다 호출하지 않는다 _invalidate_cached_approval_key() normal_close_streak = 0 if _should_alert_instant_close(instant_close_total): logger.error( f"🚨 KIS WS 접속 직후 정상 close {instant_close_total}회 연속 — " f"동일 appkey 다른 세션/서버 거부 의심") await _alert_instant_close_v2(instant_close_total) except _HANDSHAKE_STATUS_ERRORS as isc: connection_attempts += 1 _invalidate_cached_approval_key() status = _handshake_status_code(isc) if connection_attempts <= 3: logger.error(f"❌ KIS WS 핸드셰이크 거부: HTTP {status} ({type(isc).__name__})") if _httpx is not None: try: http_url = ws_full_url.replace("wss://", "https://") async with _httpx.AsyncClient(timeout=10.0, verify=True) as client: resp = await client.get(http_url, headers=extra_headers) logger.error(f"🔍 Diagnostic HTTP {resp.status_code} " f"body={(resp.text or '')[:200]}") except Exception as diag_exc: logger.error(f"🔍 Diagnostic HTTP GET failed: {diag_exc}") elif connection_attempts == 4: logger.warning( f"⚠️ KIS WS 서버 비정상 (HTTP {status} 반환). " f"백오프하며 재시도합니다. REST API는 정상 동작합니다." ) self._approval_key = None finally: # [H-3] 어떤 경로로 끝나든 등록 상태를 비운다 → 재연결 후 전량 재구독 self._reset_connection_state() except ConnectionClosed as e: connection_attempts += 1 logger.warning(f"⚡ KIS WS 연결 끊김: {e}. 재연결...") _record_disconnect_v2() self._reset_connection_state() if _session_secs < _STABLE_SESSION_SECS: # 안정 세션 뒤 끊김은 키 문제가 아니다 → 캐시 키 유지(재발급 생략). # 접속 직후 끊김 반복이면 기존대로 폐기. 핸드셰이크 거부는 별도 분기에서 폐기. self._approval_key = None _invalidate_cached_approval_key() except asyncio.CancelledError: logger.info("⏹️ KIS 실시간 스트림 종료 요청") break except Exception as e: connection_attempts += 1 if connection_attempts <= max_connection_attempts: logger.error(f"❌ KIS WS 오류 (시도 #{connection_attempts}): {type(e).__name__}: {e}", exc_info=True) else: logger.error(f"❌ KIS WS 오류 (시도 #{connection_attempts}): {type(e).__name__}: {e}") self._reset_connection_state() self._approval_key = None _invalidate_cached_approval_key() if _session_secs >= _STABLE_SESSION_SECS: # 충분히 유지된 세션 뒤의 끊김은 새 장애의 첫 시도로 취급 → 기본 대기 connection_attempts = 1 if self._running: # 지수 백오프: 연속 실패 시 대기 시간 증가 (KIS IP 차단 방지) backoff = _reconnect_backoff_v2(connection_attempts, _session_secs) logger.info(f"⏳ {backoff:.0f}초 후 재연결 (시도 #{connection_attempts})") await asyncio.sleep(backoff) finally: dispatch_task.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await dispatch_task self._running = False logger.info("🔴 KIS 실시간 스트림 종료") def stop(self): self._running = False # ── 싱글턴 인스턴스 ────────────────────────────────────────────────────────── kis_stream = KisRealtimeStream() async def run_kis_realtime_stream(): """lifespan에서 create_task()로 호출되는 진입점.""" await kis_stream.run()