import asyncio import json import logging from typing import Any, Callable, Dict, List, Optional, Union import websockets from websockets.exceptions import ConnectionClosed, WebSocketException from .config import get_ws_url, WS_TIMEOUT logger = logging.getLogger(__name__) class WebSocketError(Exception): """Custom exception for WebSocket errors""" def __init__(self, message: str, error_data: dict = None): self.message = message self.error_data = error_data or {} super().__init__(message) class RealTimeData: """실시간 데이터를 담는 클래스""" def __init__(self, data: Dict[str, Any]): self.raw_data = data self.trnm = data.get('trnm') self.return_code = data.get('return_code') self.return_msg = data.get('return_msg') self.data = data.get('data', []) class WebSocketClient: """키움증권 실시간 웹소켓 클라이언트""" def __init__( self, access_token: str, ws_url: Optional[str] = None, auto_reconnect: bool = True, reconnect_interval: int = 5, ping_interval: int = 30 ): """ 웹소켓 클라이언트 초기화 Args: access_token: 액세스 토큰 ws_url: 웹소켓 URL (None이면 설정에서 자동 선택) auto_reconnect: 자동 재연결 여부 reconnect_interval: 재연결 간격 (초) ping_interval: PING 간격 (초) """ self.access_token = access_token self.ws_url = ws_url or get_ws_url() self.auto_reconnect = auto_reconnect self.reconnect_interval = reconnect_interval self.ping_interval = ping_interval self.websocket: Optional[websockets.WebSocketServerProtocol] = None self.connected = False self.keep_running = True self.is_logged_in = False # 콜백 함수들 self.on_connect: Optional[Callable] = None self.on_disconnect: Optional[Callable] = None self.on_login: Optional[Callable] = None self.on_data: Optional[Callable[[RealTimeData], None]] = None self.on_error: Optional[Callable[[Exception], None]] = None # 태스크들 self._receive_task: Optional[asyncio.Task] = None self._ping_task: Optional[asyncio.Task] = None async def connect(self) -> None: """웹소켓 서버에 연결""" try: logger.info(f"웹소켓 서버에 연결 중: {self.ws_url}") self.websocket = await websockets.connect( self.ws_url, ping_interval=self.ping_interval, ping_timeout=WS_TIMEOUT ) self.connected = True logger.info("웹소켓 서버 연결 성공") if self.on_connect: await self.on_connect() except Exception as e: logger.error(f"웹소켓 연결 실패: {e}") self.connected = False if self.on_error: await self.on_error(e) raise WebSocketError(f"연결 실패: {e}") async def login(self) -> None: """웹소켓 서버에 로그인""" if not self.connected: await self.connect() login_data = { 'trnm': 'LOGIN', 'token': self.access_token } await self.send(login_data) logger.info("로그인 요청 전송") async def send(self, message: Union[Dict[str, Any], str]) -> None: """메시지 전송""" if not self.connected: if self.auto_reconnect: await self.connect() else: raise WebSocketError("웹소켓이 연결되지 않았습니다") try: if isinstance(message, dict): message_str = json.dumps(message) else: message_str = message await self.websocket.send(message_str) logger.debug(f"메시지 전송: {message_str}") except Exception as e: logger.error(f"메시지 전송 실패: {e}") if self.on_error: await self.on_error(e) raise WebSocketError(f"메시지 전송 실패: {e}") async def register_realtime( self, group_no: str = "1", type_list: List[str] = None, item_list: List[str] = None, refresh: str = "1" ) -> None: """ 실시간 데이터 등록 Args: group_no: 그룹 번호 type_list: 실시간 항목 리스트 (예: ['04', '0A', '0B']) item_list: 종목코드 리스트 (빈 리스트면 전체) refresh: 기존등록유지여부 (0: 기존유지안함, 1: 기존유지) """ if type_list is None: type_list = ['04'] # 기본값: 잔고 if item_list is None: item_list = [''] # 빈 문자열은 전체 종목 register_data = { 'trnm': 'REG', 'grp_no': group_no, 'refresh': refresh, 'data': [{ 'item': item_list, 'type': type_list }] } await self.send(register_data) logger.info(f"실시간 데이터 등록: {type_list}") async def unregister_realtime(self, group_no: str = "1") -> None: """실시간 데이터 해지""" unregister_data = { 'trnm': 'REMOVE', 'grp_no': group_no } await self.send(unregister_data) logger.info("실시간 데이터 해지") async def _handle_message(self, message: str) -> None: """메시지 처리""" try: data = json.loads(message) realtime_data = RealTimeData(data) trnm = realtime_data.trnm if trnm == 'LOGIN': if realtime_data.return_code == 0: self.is_logged_in = True logger.info("로그인 성공") if self.on_login: await self.on_login() else: error_msg = realtime_data.return_msg or "로그인 실패" logger.error(f"로그인 실패: {error_msg}") if self.on_error: await self.on_error(WebSocketError(error_msg)) elif trnm == 'PING': # PING에 PONG으로 응답 await self.send(data) logger.debug("PING-PONG 응답") elif trnm == 'REAL': # 실시간 데이터 수신 logger.debug(f"실시간 데이터 수신: {data}") if self.on_data: await self.on_data(realtime_data) else: # 기타 응답 logger.debug(f"기타 응답 수신: {data}") if self.on_data: await self.on_data(realtime_data) except json.JSONDecodeError as e: logger.error(f"JSON 파싱 오류: {e}") if self.on_error: await self.on_error(e) except Exception as e: logger.error(f"메시지 처리 오류: {e}") if self.on_error: await self.on_error(e) async def _receive_messages(self) -> None: """메시지 수신 루프""" while self.keep_running: try: if not self.connected or not self.websocket: break message = await self.websocket.recv() await self._handle_message(message) except ConnectionClosed: logger.warning("웹소켓 연결이 종료되었습니다") self.connected = False self.is_logged_in = False if self.on_disconnect: await self.on_disconnect() if self.auto_reconnect and self.keep_running: logger.info(f"{self.reconnect_interval}초 후 재연결을 시도합니다") await asyncio.sleep(self.reconnect_interval) try: await self.connect() await self.login() except Exception as e: logger.error(f"재연결 실패: {e}") else: break except Exception as e: logger.error(f"메시지 수신 오류: {e}") if self.on_error: await self.on_error(e) async def _ping_loop(self) -> None: """PING 루프 (연결 유지)""" while self.keep_running and self.connected: try: await asyncio.sleep(self.ping_interval) if self.connected and self.websocket: await self.websocket.ping() logger.debug("PING 전송") except Exception as e: logger.error(f"PING 오류: {e}") async def start(self) -> None: """웹소켓 클라이언트 시작""" try: await self.connect() await self.login() # 수신 및 PING 태스크 시작 self._receive_task = asyncio.create_task(self._receive_messages()) self._ping_task = asyncio.create_task(self._ping_loop()) logger.info("웹소켓 클라이언트 시작됨") except Exception as e: logger.error(f"웹소켓 클라이언트 시작 실패: {e}") raise async def stop(self) -> None: """웹소켓 클라이언트 중지""" logger.info("웹소켓 클라이언트 중지 중...") self.keep_running = False # 태스크들 취소 if self._receive_task: self._receive_task.cancel() if self._ping_task: self._ping_task.cancel() # 웹소켓 연결 종료 if self.websocket: await self.websocket.close() self.connected = False self.is_logged_in = False logger.info("웹소켓 클라이언트 중지됨") async def run_forever(self) -> None: """무한 루프로 실행""" try: await self.start() # 태스크들이 완료될 때까지 대기 if self._receive_task: await self._receive_task except asyncio.CancelledError: logger.info("웹소켓 클라이언트가 취소되었습니다") except Exception as e: logger.error(f"웹소켓 클라이언트 실행 오류: {e}") finally: await self.stop() def run_sync(self) -> None: """동기적으로 실행 (새로운 이벤트 루프에서)""" try: asyncio.run(self.run_forever()) except KeyboardInterrupt: logger.info("사용자에 의해 중단되었습니다") except Exception as e: logger.error(f"동기 실행 오류: {e}") raise