fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
@@ -14,6 +14,7 @@ KIS WebSocket 실시간 시세 수신 서비스
|
||||
- lifespan()에서 asyncio.create_task()로 기동
|
||||
"""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
@@ -27,19 +28,62 @@ except ImportError:
|
||||
|
||||
try:
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed, InvalidStatusCode
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
except ImportError:
|
||||
websockets = None # type: ignore
|
||||
ConnectionClosed = Exception # type: ignore
|
||||
InvalidStatusCode = 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
|
||||
@@ -55,10 +99,58 @@ _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
|
||||
|
||||
|
||||
@@ -81,7 +173,15 @@ def _create_ssl_context() -> ssl.SSLContext:
|
||||
def _get_ws_url() -> str:
|
||||
# [이슈4 수정] default값이 True이면 True == "virtual" → False → 실전 URL 반환 오류.
|
||||
# KIS_MODE 기본값은 "virtual" 문자열로 지정해야 함.
|
||||
return _KIS_WS_VIRTUAL if getattr(settings, "KIS_MODE", "virtual") == "virtual" else _KIS_WS_REAL
|
||||
# 대소문자 무관: '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]:
|
||||
@@ -183,6 +283,23 @@ def _build_subscribe_msg(approval_key: str, tr_id: str, stock_code: str,
|
||||
})
|
||||
|
||||
|
||||
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 실시간 메시지 파싱.
|
||||
@@ -212,8 +329,35 @@ def _parse_realtime_message(raw: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
_enc, tr_id, _cnt, body = parts[0], parts[1], parts[2], parts[3]
|
||||
fields = body.split("^")
|
||||
# 다건 프레임(_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]=전일대비율(%)
|
||||
@@ -226,6 +370,10 @@ def _parse_realtime_message(raw: str) -> Optional[dict]:
|
||||
"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 "",
|
||||
@@ -253,6 +401,35 @@ def _parse_realtime_message(raw: str) -> Optional[dict]:
|
||||
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)
|
||||
@@ -283,6 +460,35 @@ async def _cache_realtime_data(msg_type: str, stock_code: str, data: dict) -> No
|
||||
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 실시간 스트림 관리 클래스.
|
||||
@@ -291,23 +497,40 @@ class KisRealtimeStream:
|
||||
run() 진입
|
||||
→ 1단계: _fetch_approval_key() REST 호출
|
||||
→ 2단계: websockets.connect() WebSocket 연결
|
||||
→ 3단계: _sync_subscriptions() 구독 메시지 전송
|
||||
→ 4단계: async for raw_msg in ws: 수신 → 파싱
|
||||
→ Redis SETEX 캐시 (_cache_realtime_data)
|
||||
→ websocket_manager 브로드캐스트
|
||||
→ 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 동기화 트리거 (set_subscriptions 호출 직후 사용)"""
|
||||
"""구독 목록 변경 시 즉시 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:
|
||||
@@ -317,40 +540,183 @@ class KisRealtimeStream:
|
||||
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):
|
||||
msg = _build_subscribe_msg(self._approval_key, tr_id, code, tr_type)
|
||||
await ws.send(msg)
|
||||
await asyncio.sleep(0.05) # rate-limit 방지
|
||||
await self._send_registration(ws, tr_id, code, tr_type)
|
||||
|
||||
async def _sync_subscriptions(self, ws):
|
||||
"""websocket_manager 구독 목록과 KIS 구독 상태를 동기화."""
|
||||
target = websocket_manager.get_all_subscribed_codes()
|
||||
to_add = target - self._subscribed_codes
|
||||
to_remove = self._subscribed_codes - target
|
||||
"""websocket_manager 구독 목록과 KIS 등록 상태를 동기화.
|
||||
|
||||
for code in to_add:
|
||||
logger.info(f"📡 KIS 구독 추가: {code}")
|
||||
await self._send_subscribe(ws, code, "1")
|
||||
for code in to_remove:
|
||||
logger.info(f"🚫 KIS 구독 해제: {code}")
|
||||
await self._send_subscribe(ws, code, "2")
|
||||
[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)
|
||||
|
||||
self._subscribed_codes = target.copy()
|
||||
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단계 반복.
|
||||
연결 끊김/오류 시 approval_key 재발급 후 재연결.
|
||||
|
||||
주의: KIS WebSocket이 응답하지 않으면 일부 설정을 확인하십시오:
|
||||
- .env: KIS_WS_VIRTUAL_URL / KIS_WS_REAL_URL (포트, 경로)
|
||||
- KIS 모드: KIS_MODE (virtual/real)
|
||||
- 재시도: 최대 5회 후 경고만 표시 (무한 루프 방지)
|
||||
연결 끊김/오류/정상 close 시 등록 상태를 비우고(H-3) approval_key 재발급 후 재연결.
|
||||
"""
|
||||
if websockets is None:
|
||||
logger.error("❌ websockets 패키지 미설치. pip install websockets")
|
||||
@@ -359,179 +725,155 @@ class KisRealtimeStream:
|
||||
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())
|
||||
|
||||
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
|
||||
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 연결 ────────────────────────────────────
|
||||
try:
|
||||
ssl_context = _create_ssl_context()
|
||||
|
||||
# WebSocket URL: KIS 공식 WebSocket은 경로 없이 루트(/)로 연결
|
||||
# config에 이미 /websocket 등 경로가 포함되어 있으면 그대로 사용
|
||||
ws_full_url = ws_url
|
||||
|
||||
if connection_attempts < max_connection_attempts:
|
||||
logger.info(f"🚀 KIS WS 접속 시도 #{connection_attempts + 1}: {ws_full_url} (key_len={len(self._approval_key) if self._approval_key else 0})")
|
||||
else:
|
||||
if connection_attempts == max_connection_attempts:
|
||||
# ── 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}회 연결 실패. 앞으로 30초마다 시도합니다.\n"
|
||||
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"
|
||||
f" 4. KIS 서버가 WebSocket을 지원하나? (REST API만 지원할 수도 있음)\n"
|
||||
)
|
||||
logger.debug(f"🔄 KIS WS 재연결 시도 #{connection_attempts + 1}...")
|
||||
# Allow optional override via settings, otherwise derive an Origin from the ws url
|
||||
origin = getattr(settings, "KIS_WS_ORIGIN", None)
|
||||
if not origin:
|
||||
try:
|
||||
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]
|
||||
except Exception:
|
||||
origin = None
|
||||
|
||||
# 헤더에 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
|
||||
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,
|
||||
#ssl=ssl_context,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
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
|
||||
|
||||
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 연결 완료")
|
||||
|
||||
# ── 3단계: 초기 구독 ──────────────────────────────────
|
||||
await self._sync_subscriptions(ws)
|
||||
last_sync = time.monotonic()
|
||||
|
||||
# ── 4단계: 데이터 수신 루프 ──────────────────────────
|
||||
async for raw_msg in ws:
|
||||
# 주기적 구독 동기화 (10초 주기 OR 구독 변경 즉시)
|
||||
if self._sync_event.is_set() or time.monotonic() - last_sync >= _SYNC_INTERVAL:
|
||||
self._sync_event.clear()
|
||||
await self._sync_subscriptions(ws)
|
||||
last_sync = time.monotonic()
|
||||
|
||||
parsed = _parse_realtime_message(raw_msg)
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
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)
|
||||
# 진단 로그는 처음 3회만 출력 (로그 도배 방지)
|
||||
if connection_attempts <= 3:
|
||||
logger.error(f"❌ KIS WS InvalidStatusCode: HTTP {status}")
|
||||
if _httpx is not None:
|
||||
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:
|
||||
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=True) as client:
|
||||
diag_headers = {"User-Agent": "Mozilla/5.0"}
|
||||
if origin:
|
||||
diag_headers["Origin"] = origin
|
||||
resp = await client.get(http_url, headers=diag_headers)
|
||||
body_snippet = (resp.text or '')[:200]
|
||||
logger.error(f"🔍 Diagnostic HTTP {resp.status_code} body={body_snippet}")
|
||||
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"5분 간격으로 재시도합니다. REST API는 정상 동작합니다."
|
||||
)
|
||||
# reset ws state
|
||||
self._ws = None
|
||||
self._subscribed_codes.clear()
|
||||
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
|
||||
# outer backoff로 이동 (continue 제거)
|
||||
_invalidate_cached_approval_key()
|
||||
|
||||
except ConnectionClosed as e:
|
||||
connection_attempts += 1
|
||||
logger.warning(f"⚡ KIS WS 연결 끊김: {e}. {_RECONNECT_WAIT}초 후 재연결...")
|
||||
self._ws = None
|
||||
self._subscribed_codes.clear()
|
||||
# 재연결 시 approval_key 재발급
|
||||
self._approval_key = None
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("⏹️ KIS 실시간 스트림 종료 요청")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
if self._running:
|
||||
# [FIX] 지수 백오프: 연속 실패 시 대기 시간 증가 (KIS IP 차단 방지)
|
||||
backoff = min(_RECONNECT_WAIT * (2 ** min(connection_attempts - 1, 6)), _RECONNECT_MAX_WAIT)
|
||||
logger.info(f"⏳ {backoff:.0f}초 후 재연결 (시도 #{connection_attempts})")
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
self._running = False
|
||||
logger.info("🔴 KIS 실시간 스트림 종료")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user