Files
stockautomtion/app/services/realtime.py
gerd 182c2b38e6 feat: config.yaml 기반 설정 마이그레이션 및 대시보드/종목조회 기능 추가
- config.yaml에서 직접 KIS 설정 관리 (BaseSettings 제거)
- 대시보드 KIS 계좌 잔고 실시간 연동, 로컬 DB 폴백
- 종목 조회 페이지: 시세/호가/일봉차트 (canvas) 구현
- 호가 REST API 엔드포인트 (/api/stocks/{code}/orderbook) 추가
- 토큰 캐시(.auth_cache.json) 저장/복원, 1분 재시도 제한
- KIS WebSocket 자동 연결 + 재연결 로직
- httpx 타임아웃 10초→30초, 라우터 타임아웃 핸들링 (504)
- rate limit: requests_per_second 2.0 (모의투자 초당 2건)
- 사이드바 3메뉴: 실거래 / 과거기록 / 종목조회
2026-07-17 15:25:34 +09:00

206 lines
6.9 KiB
Python

from __future__ import annotations
import asyncio
import json
from typing import Callable
import websockets
from app.core.auth import token_manager
from app.core.config import get_ws_url, settings
from app.core.logger import setup_logger
from app.core.rate_limiter import RateLimiter
logger = setup_logger("realtime")
_SUBSCRIBE_TR_IDS = {
"price": "H0STCNT0",
"orderbook": "H0STASP0",
"trade": "H0STCNI0",
}
class RealtimeService:
def __init__(self) -> None:
self._ws: websockets.WebSocketClientProtocol | None = None
self._running = False
self._callbacks: dict[str, list[Callable]] = {}
self._subscriptions: dict[str, list[str]] = {}
self._rate_limiter = RateLimiter(requests_per_second=0.5)
self._heartbeat_task: asyncio.Task | None = None
self._receive_task: asyncio.Task | None = None
@property
def is_connected(self) -> bool:
return self._ws is not None and self._running
def on(self, event: str, callback: Callable) -> None:
self._callbacks.setdefault(event, []).append(callback)
async def connect(self) -> None:
approval_key = await token_manager.get_approval_key()
url = get_ws_url()
try:
self._ws = await websockets.connect(
url,
additional_headers={"approval_key": approval_key, "type": "Y"},
)
self._running = True
self._receive_task = asyncio.create_task(self._receive_loop())
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
logger.info("WebSocket 연결 성공: %s", url)
except Exception as e:
logger.error("WebSocket 연결 실패: %s", e)
raise
async def subscribe(self, stock_code: str, data_type: str = "price") -> None:
if not self._ws:
await self.connect()
tr_id = _SUBSCRIBE_TR_IDS.get(data_type, "H0STCNT0")
msg = {
"header": {
"approval_key": await token_manager.get_approval_key(),
"custtype": "P",
"tr_type": "1",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": stock_code,
}
},
}
await self._ws.send(json.dumps(msg))
self._subscriptions.setdefault(data_type, []).append(stock_code)
logger.info("구독 요청: %s - %s", data_type, stock_code)
async def unsubscribe(self, stock_code: str, data_type: str = "price") -> None:
if not self._ws:
return
tr_id = _SUBSCRIBE_TR_IDS.get(data_type, "H0STCNT0")
msg = {
"header": {
"approval_key": await token_manager.get_approval_key(),
"custtype": "P",
"tr_type": "2",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": tr_id,
"tr_key": stock_code,
}
},
}
await self._ws.send(json.dumps(msg))
subs = self._subscriptions.get(data_type, [])
if stock_code in subs:
subs.remove(stock_code)
logger.info("구독 해제: %s - %s", data_type, stock_code)
async def _receive_loop(self) -> None:
try:
async for raw_msg in self._ws:
await self._handle_message(raw_msg)
except websockets.ConnectionClosed:
logger.warning("WebSocket 연결 끊김. 5초 후 재연결...")
except Exception as e:
logger.error("WebSocket 수신 오류: %s", e)
if self._running:
await asyncio.sleep(5)
try:
await self.connect()
except Exception:
logger.error("WebSocket 재연결 실패")
async def _handle_message(self, raw_msg: str) -> None:
try:
msg = json.loads(raw_msg)
header = msg.get("header", {})
body = msg.get("body", {})
tr_id = header.get("tr_id", "")
if tr_id == "PINGPONG":
return
parsed = self._parse_data(tr_id, body.get("output", {}))
if not parsed:
return
event_type = "price"
if "bid_prices" in parsed:
event_type = "orderbook"
elif "trade_price" in parsed:
event_type = "trade"
for callback in self._callbacks.get(event_type, []):
try:
result = callback(parsed)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error("콜백 실행 오류: %s", e)
except json.JSONDecodeError:
logger.warning("JSON 파싱 실패")
def _parse_data(self, tr_id: str, output: dict) -> dict | None:
if not output:
return None
if tr_id == "H0STCNT0":
return {
"type": "price",
"stock_code": output.get("mksc_shrn_iscd", ""),
"trade_price": int(output.get("stck_prpr", 0)),
"change_price": int(output.get("prdy_vrss", 0)),
"change_rate": float(output.get("prdy_ctrt", 0)),
"open_price": int(output.get("stck_oprc", 0)),
"high_price": int(output.get("stck_hgpr", 0)),
"low_price": int(output.get("stck_lwpr", 0)),
"volume": int(output.get("acml_vol", 0)),
"trade_time": output.get("stck_cntg_hour", ""),
}
if tr_id == "H0STASP0":
return {
"type": "orderbook",
"stock_code": output.get("mksc_shrn_iscd", ""),
"bid_prices": [int(output.get(f"phsc_kprc_{i}", 0)) for i in range(1, 6)],
"ask_prices": [int(output.get(f"sats_kprc_{i}", 0)) for i in range(1, 6)],
"bid_volumes": [int(output.get(f"phsc_vola_{i}", 0)) for i in range(1, 6)],
"ask_volumes": [int(output.get(f"sats_ac_vola_{i}", 0)) for i in range(1, 6)],
}
return None
async def _heartbeat_loop(self) -> None:
while self._running:
try:
await asyncio.sleep(30)
if self._ws and self._ws.open:
await self._ws.send(json.dumps({"header": {"tr_id": "PINGPONG"}}))
except Exception:
break
async def disconnect(self) -> None:
self._running = False
if self._heartbeat_task:
self._heartbeat_task.cancel()
if self._receive_task:
self._receive_task.cancel()
if self._ws:
await self._ws.close()
self._ws = None
logger.info("WebSocket 연결 종료")
realtime_service = RealtimeService()