변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2166 lines
94 KiB
Python
2166 lines
94 KiB
Python
"""
|
||
kis_trader/ws/kis_ws.py — KIS WebSocket 실시간 체결가 캐시 (H0STCNT0)
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
역할: check_sell_signals() 의 inquire_price() REST 폴링을 대체.
|
||
보유 종목 코드를 구독해두면 KIS가 체결마다 push → 즉시 가격 캐시 갱신.
|
||
|
||
WebSocket vs REST:
|
||
REST : 요청마다 0.5s 딜레이(API 제한) + 응답 대기 → 보유 3종목 ≈ 3초 낭비
|
||
WebSocket: 연결 1회 + push 수신 → 체결 즉시 캐시 갱신, API 카운트 무관
|
||
|
||
KIS 공식 스펙:
|
||
TR_ID : H0STCNT0 (국내주식 실시간체결가)
|
||
실전 URL : ws://ops.koreainvestment.com:21000 (KIS_WS_URL_REAL, env/DB 변경 가능)
|
||
모의 URL : ws://ops.koreainvestment.com:31000 (KIS_WS_URL_MOCK, env/DB 변경 가능)
|
||
세션 구독 한도: 최대 41종목 (MAX_STOCKS=3~4 수준에서 문제 없음)
|
||
|
||
KIS 2026-02-24 경고 준수 (무한 재연결 차단 정책):
|
||
- 재연결 횟수 제한: 1시간 내 MAX_RECONNECTS_PER_HOUR 회 초과 시 자동 대기
|
||
- 최대 총 재연결 횟수: MAX_RECONNECT_ATTEMPTS 회 초과 시 WebSocket 종료 → REST fallback
|
||
- 지수 백오프: 5초 → 10초 → 20초 ... 최대 300초
|
||
|
||
모의투자(VTS):
|
||
KIS 모의투자는 WebSocket을 지원하지 않는 경우가 많음.
|
||
연결 실패 시 is_active=False → check_sell_signals()가 REST로 자동 fallback.
|
||
KIS_WS_MOCK_ENABLED=true (env/DB) 로 강제 활성화 가능.
|
||
|
||
설치 필요:
|
||
pip install websocket-client
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import queue
|
||
import random
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Optional, Set
|
||
|
||
import requests
|
||
|
||
logger = logging.getLogger("KISWebSocket")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 모듈 수준에서 get_env 함수를 참조 (kis_long_ver1 공용 함수 재사용)
|
||
# 이 파일이 단독으로도 동작할 수 있도록 fallback import 포함
|
||
# ------------------------------------------------------------------
|
||
try:
|
||
from kis_trader.utils.env import get_env_from_db, get_env_int, get_env_bool, get_env_float
|
||
except ImportError:
|
||
try:
|
||
from kis_long_ver1 import get_env_from_db, get_env_int, get_env_bool
|
||
except ImportError:
|
||
# 모듈 임포트 전 단계에서는 기본값만 사용
|
||
def get_env_from_db(key, default=""): # type: ignore[misc]
|
||
return default
|
||
|
||
def get_env_int(key, default): # type: ignore[misc]
|
||
return default
|
||
|
||
def get_env_bool(key, default=False): # type: ignore[misc]
|
||
return default
|
||
|
||
def get_env_float(key, default): # type: ignore[misc]
|
||
return default
|
||
|
||
|
||
class KISWebSocketPriceCache:
|
||
"""
|
||
KIS H0STCNT0 실시간 체결가 WebSocket 수신기.
|
||
|
||
사용법:
|
||
ws_cache = KISWebSocketPriceCache(app_key, app_secret, is_mock=False)
|
||
ok = ws_cache.start() # 백그라운드 스레드 시작
|
||
ws_cache.subscribe("005930") # 종목 구독
|
||
data = ws_cache.get_price("005930") # inquire_price 호환 dict 반환
|
||
ws_cache.stop()
|
||
|
||
get_price() 반환 값이 None 이면 → REST inquire_price() 로 fallback
|
||
"""
|
||
|
||
# H0STCNT0 데이터 필드 인덱스 ('^' 구분)
|
||
# KIS 공식 columns (open-trading-api ccnl_krx / MCP 확인):
|
||
# 10 ASKP1, 11 BIDP1, 12 CNTG_VOL(체결량), 13 ACML_VOL(누적거래량)
|
||
# 과거 IDX_VOLUME=11 은 BIDP1(매수호가≈가격)을 읽어 ws_ticks.volume 이 오염됨.
|
||
IDX_CODE = 0 # MKSC_SHRN_ISCD: 유가증권 단축 종목코드
|
||
IDX_TIME = 1 # STCK_CNTG_HOUR: 체결 시간
|
||
IDX_PRICE = 2 # STCK_PRPR: 주식 현재가 (체결가)
|
||
IDX_SIGN = 3 # PRDY_VRSS_SIGN: 전일 대비 부호
|
||
IDX_CHANGE = 4 # PRDY_VRSS: 전일 대비
|
||
IDX_CHGPCT = 5 # PRDY_CTRT: 전일 대비율
|
||
# 당일 시고저 (매수 체크 시 REST inquire_price 대체용 → API 과부하 방지)
|
||
IDX_OPEN = 7 # STCK_OPRC: 주식 시가 (당일)
|
||
IDX_HIGH = 8 # STCK_HGPR: 주식 고가 (당일)
|
||
IDX_LOW = 9 # STCK_LWPR: 주식 저가 (당일)
|
||
IDX_ASKP1 = 10 # ASKP1: 매도호가1
|
||
IDX_BIDP1 = 11 # BIDP1: 매수호가1
|
||
IDX_CNTG_VOL = 12 # CNTG_VOL: 체결 거래량 (틱당, TickRecorder용)
|
||
IDX_ACML_VOL = 13 # ACML_VOL: 누적 거래량 (봉 델타용)
|
||
IDX_VOLUME = 12 # 하위호환 alias → CNTG_VOL
|
||
|
||
# 재연결 정책 (KIS 2026-02-24 경고 준수)
|
||
MAX_RECONNECT_ATTEMPTS = 10 # 총 재연결 최대 횟수 (STABLE_CONN_RESET_SEC 이상 안정 연결 후 끊기면 초기화)
|
||
MAX_RECONNECTS_PER_HOUR = 6 # 1시간 내 재연결 허용 횟수
|
||
RECONNECT_BASE_DELAY_SEC = 5.0 # 초기 재연결 대기(초)
|
||
RECONNECT_MAX_DELAY_SEC = 300.0 # 최대 재연결 대기(초)
|
||
# 이 시간(초) 이상 안정적으로 연결이 유지됐다가 끊기면 카운터를 초기화.
|
||
# 예: 5분 이상 정상 운영 후 네트워크 일시 장애 → '버스트 차단'이 아닌 '정상 재연결'로 간주.
|
||
STABLE_CONN_RESET_SEC = 300.0 # 5분
|
||
|
||
# Approval key 유효시간 (23시간 캐시). KIS: access_token 갱신주기 6시간·유효 24시간.
|
||
APPROVAL_KEY_CACHE_SEC = 82800
|
||
|
||
def __init__(self, app_key: str, app_secret: str, is_mock: bool = True):
|
||
self.app_key = app_key
|
||
self.app_secret = app_secret
|
||
self.is_mock = is_mock
|
||
|
||
# WebSocket URL (env/DB 로 재정의 가능 → 연결 실패 시 사용자가 수정)
|
||
_default_real = "ws://ops.koreainvestment.com:21000"
|
||
_default_mock = "ws://ops.koreainvestment.com:31000"
|
||
self._ws_url = (
|
||
get_env_from_db("KIS_WS_URL_MOCK", _default_mock)
|
||
if is_mock
|
||
else get_env_from_db("KIS_WS_URL_REAL", _default_real)
|
||
)
|
||
self._base_url = (
|
||
"https://openapivts.koreainvestment.com:29443"
|
||
if is_mock
|
||
else "https://openapi.koreainvestment.com:9443"
|
||
)
|
||
|
||
# ── 가격 캐시 ──────────────────────────────────────────────
|
||
# { code: {"data": dict, "ts": float} }
|
||
self._cache: Dict[str, Dict] = {}
|
||
self._cache_lock = threading.Lock()
|
||
|
||
# ── 구독 목록 ──────────────────────────────────────────────
|
||
self._subscribed: Set[str] = set()
|
||
self._sub_lock = threading.Lock()
|
||
|
||
# ── 영구 구독 목록 (홀딩 관심종목 등) ──────────────────────
|
||
# unsubscribe() 호출에도 해제되지 않는 고정 구독 코드
|
||
self._permanent_codes: Set[str] = set()
|
||
self._load_permanent_watchlist()
|
||
|
||
# ── WebSocket 관련 ─────────────────────────────────────────
|
||
self._ws = None # 현재 WebSocketApp 인스턴스
|
||
self._ws_thread: Optional[threading.Thread] = None
|
||
self._running = False
|
||
self._connected = False
|
||
|
||
# ── approval_key (REST 토큰과 별개, WebSocket 전용 인증) ───
|
||
self._approval_key: Optional[str] = None
|
||
self._approval_key_ts: float = 0.0
|
||
|
||
# ── 재연결 관리 ────────────────────────────────────────────
|
||
self._reconnect_count = 0
|
||
self._reconnect_times: list = [] # 최근 재연결 타임스탬프 목록
|
||
self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC
|
||
self._last_connect_time: float = 0.0 # 마지막 연결 성공 시각 (안정 연결 판단용)
|
||
self._last_subscribe_error: str = "" # H0STCNT0 JSON rt_cd≠0 (즉시 끊김 원인 추적)
|
||
self._subscribe_resend_lock = threading.Lock()
|
||
|
||
# ── CandleAggregator (스캘핑봇 연동 시 외부에서 주입) ─────
|
||
# attach_candle_aggregator(agg) 로 연결, None이면 봉 집계 비활성
|
||
self._candle_agg: Optional["CandleAggregator"] = None
|
||
# ── TickRecorder (C안: RAM 링버퍼 + ws_ticks 배치) ────────
|
||
self._tick_recorder: Optional[Any] = None
|
||
|
||
# ── WS 연결 성공 시 갭보정 콜백 ───────────────────────────
|
||
# set_on_connected_callback(fn) 으로 등록.
|
||
# 연결 성공(_on_open) 후 별도 스레드에서 호출됨.
|
||
# 장 시간일 때만 실행 (새벽 재연결 시 API 빈 응답 방지)
|
||
self._on_connected_callback: Optional[callable] = None
|
||
|
||
# ── websocket-client 사용 가능 여부 ───────────────────────
|
||
try:
|
||
import websocket as _ws_lib
|
||
self._ws_lib = _ws_lib
|
||
self._available = True
|
||
except ImportError:
|
||
self._ws_lib = None
|
||
self._available = False
|
||
logger.warning(
|
||
"⚠️ websocket-client 미설치 → WebSocket 실시간 가격 비활성.\n"
|
||
" pip install websocket-client 를 실행하세요."
|
||
)
|
||
|
||
# ==================================================================
|
||
# Public API
|
||
# ==================================================================
|
||
|
||
def attach_candle_aggregator(self, agg: "CandleAggregator") -> None:
|
||
"""
|
||
CandleAggregator 를 연결합니다.
|
||
연결 후부터 틱 수신 시 자동으로 agg.on_tick() 이 호출됩니다.
|
||
kis_scalping_ver1.py 에서 ws_cache.attach_candle_aggregator(agg) 로 호출.
|
||
"""
|
||
self._candle_agg = agg
|
||
logger.info("✅ CandleAggregator 연결 완료 (봉 집계 활성화)")
|
||
|
||
def attach_tick_recorder(self, recorder: Any) -> None:
|
||
"""TickRecorder 연결 — H0STCNT0 틱 → RAM 링버퍼 + ws_ticks 배치."""
|
||
self._tick_recorder = recorder
|
||
logger.info("✅ TickRecorder 연결 완료 (KIS H0STCNT0)")
|
||
|
||
def set_on_connected_callback(self, fn) -> None:
|
||
"""
|
||
WS 연결 성공(_on_open) 시 호출할 콜백 등록.
|
||
ScalpingBotV1._fill_all_gaps 를 넘겨서 매 연결 시 갭보정 자동 실행.
|
||
장 시간일 때만 콜백을 실행 (새벽 자동재연결 시 API 빈 응답 방지).
|
||
"""
|
||
self._on_connected_callback = fn
|
||
|
||
def start(self, force_cleanup: bool = True) -> bool:
|
||
"""
|
||
WebSocket 수신 백그라운드 스레드를 시작합니다.
|
||
성공 시 True, 사용 불가(모의/패키지 미설치/키 없음) 시 False.
|
||
False 를 반환해도 봇은 REST fallback으로 정상 동작합니다.
|
||
"""
|
||
if not self._available:
|
||
return False
|
||
|
||
# 모의투자는 기본 비활성 (KIS 모의 서버 WebSocket 미지원 가능성)
|
||
# KIS_WS_MOCK_ENABLED=true 로 강제 활성화 가능
|
||
if self.is_mock and not get_env_bool("KIS_WS_MOCK_ENABLED", False):
|
||
logger.info("ℹ️ 모의투자: WebSocket 기본 비활성 (KIS_WS_MOCK_ENABLED=true 로 활성 가능)")
|
||
return False
|
||
|
||
if self._running:
|
||
return True
|
||
|
||
# ── [CRITICAL] 비정상 종료 후 재시작 대비: 구독·가격 캐시만 정리 ──────────────
|
||
# approval_key 는 .kis_approval_cache_*.json 파일 캐시 유지 (6h/24h KIS 정책).
|
||
# 국내·해외 WS 가 동일 키 공유 — 재시작마다 REST 재발급하면 invalid approval 유발.
|
||
if force_cleanup:
|
||
logger.info("🧹 WebSocket 세션 초기화 (구독/가격 캐시 리셋, approval_key 파일 유지)")
|
||
with self._sub_lock:
|
||
self._subscribed.clear()
|
||
with self._cache_lock:
|
||
self._cache.clear()
|
||
logger.info("✅ WebSocket 세션 초기화 완료 (구독/캐시 리셋)")
|
||
|
||
# approval_key 발급 (연결 전 확인)
|
||
if not self._get_approval_key():
|
||
logger.warning("⚠️ WebSocket approval_key 발급 실패 → REST fallback 모드")
|
||
return False
|
||
|
||
self._running = True
|
||
self._ws_thread = threading.Thread(
|
||
target=self._run_ws_loop, daemon=True, name="KIS-WS-H0STCNT0"
|
||
)
|
||
self._ws_thread.start()
|
||
logger.info(
|
||
"✅ KIS WebSocket 수신 스레드 시작 (H0STCNT0 | url=%s)", self._ws_url
|
||
)
|
||
return True
|
||
|
||
def stop(self, clear_subscriptions: bool = False) -> None:
|
||
"""
|
||
WebSocket 수신 중단 및 스레드 종료.
|
||
|
||
Args:
|
||
clear_subscriptions: True 시 모든 종목 구독 해제 후 종료
|
||
(봇 정상 종료 시 KIS 서버 정리용)
|
||
"""
|
||
# 옵션: 모든 구독 해제 (KIS 서버 정리용)
|
||
if clear_subscriptions and self._connected and self._ws:
|
||
with self._sub_lock:
|
||
codes = list(self._subscribed)
|
||
for code in codes:
|
||
self._send_sub_msg(code, subscribe=False)
|
||
with self._sub_lock:
|
||
self._subscribed.discard(code)
|
||
with self._cache_lock:
|
||
self._cache.pop(code, None)
|
||
logger.info("📡 WebSocket 구독 해제: %s", code)
|
||
logger.info("✅ 모든 WebSocket 구독 정리 완료 (%d종목)", len(codes))
|
||
|
||
self._running = False
|
||
self._connected = False
|
||
if self._ws:
|
||
try:
|
||
self._ws.close()
|
||
except Exception:
|
||
pass
|
||
if self._ws_thread and self._ws_thread.is_alive():
|
||
self._ws_thread.join(timeout=5)
|
||
logger.info("🛑 KIS WebSocket 종료")
|
||
|
||
# KIS WebSocket 세션 당 구독 가능 최대 종목 수
|
||
# 초과 시 서버에서 오류 반환 또는 계정 일시 차단 가능 (KIS 공지 준수)
|
||
MAX_SUBSCRIPTIONS = 41
|
||
|
||
def _load_permanent_watchlist(self) -> None:
|
||
"""
|
||
long_term_watchlist.json 에 있는 홀딩 관심종목을 영구 구독 목록으로 로드.
|
||
WS 연결 시 자동으로 subscribe(), unsubscribe() 호출 시에도 해제하지 않음.
|
||
"""
|
||
try:
|
||
wl_path = Path(__file__).resolve().parent / "long_term_watchlist.json"
|
||
if not wl_path.exists():
|
||
return
|
||
items = json.loads(wl_path.read_text(encoding="utf-8")).get("items", [])
|
||
codes = [i["code"] for i in items if i.get("code")]
|
||
self._permanent_codes = set(codes)
|
||
if codes:
|
||
logger.info(
|
||
"📌 홀딩 영구 구독 목록 로드: %s (%d종목)",
|
||
", ".join(codes), len(codes),
|
||
)
|
||
except Exception as e:
|
||
logger.warning("영구 구독 목록 로드 실패: %s", e)
|
||
|
||
def subscribe(self, code: str) -> None:
|
||
"""
|
||
실시간 체결가 구독 등록.
|
||
이미 연결 중이면 즉시 구독 메시지 전송, 연결 전이면 연결 성공 시 일괄 등록.
|
||
KIS 세션 한도(MAX_SUBSCRIPTIONS=41) 초과 시 등록 거부 후 경고 로그 출력.
|
||
"""
|
||
code = (code or "").strip()
|
||
if not code:
|
||
return
|
||
with self._sub_lock:
|
||
if code in self._subscribed:
|
||
return # 이미 구독 중 → 중복 전송 방지
|
||
if len(self._subscribed) >= self.MAX_SUBSCRIPTIONS:
|
||
logger.warning(
|
||
"⚠️ WebSocket 구독 한도 초과(%d/%d) → %s 구독 거부 "
|
||
"(KIS 세션 한도 준수: 불필요 종목 구독해제 후 재시도)",
|
||
len(self._subscribed), self.MAX_SUBSCRIPTIONS, code,
|
||
)
|
||
return
|
||
self._subscribed.add(code)
|
||
if self._connected and self._ws:
|
||
self._send_sub_msg(code, subscribe=True)
|
||
logger.info("📡 WebSocket 구독 추가: %s (%d/%d)", code, len(self._subscribed), self.MAX_SUBSCRIPTIONS)
|
||
|
||
def unsubscribe(self, code: str) -> None:
|
||
"""
|
||
실시간 체결가 구독 해제 및 캐시 삭제.
|
||
단, long_term_watchlist.json 의 영구 구독 종목은 해제하지 않음.
|
||
"""
|
||
code = (code or "").strip()
|
||
if code in self._permanent_codes:
|
||
logger.debug("📌 영구 구독 종목 해제 요청 무시: %s (홀딩 관심종목)", code)
|
||
return
|
||
with self._sub_lock:
|
||
self._subscribed.discard(code)
|
||
with self._cache_lock:
|
||
self._cache.pop(code, None)
|
||
if self._connected and self._ws:
|
||
self._send_sub_msg(code, subscribe=False)
|
||
logger.info("📡 WebSocket 구독 해제: %s", code)
|
||
|
||
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[Dict]:
|
||
"""
|
||
WebSocket 캐시에서 실시간 가격 + 당일 시고저를 꺼냅니다.
|
||
max_age_sec 초 이내 수신된 체결 틱만 유효하게 취급합니다.
|
||
|
||
반환 형식: {
|
||
"stck_prpr": "73900", # 현재가 (체결가, REST inquire_price와 동일)
|
||
"prdy_vrss": "200", # 전일 대비
|
||
"prdy_ctrt": "0.27", # 전일 대비율
|
||
"stck_oprc": "73000", # 당일 시가 (REST와 동일)
|
||
"stck_hgpr": "74500", # 당일 고가 (REST와 동일)
|
||
"stck_lwpr": "72800", # 당일 저가 (REST와 동일)
|
||
}
|
||
반환 None: WebSocket 미연결 / 데이터 없음 / max_age_sec 초 초과 → REST fallback 권장
|
||
|
||
※ WS 캐시 정확도: H0STCNT0 체결 틱마다 KIS 서버가 시고저를 함께 전송
|
||
→ 장중 REST inquire_price와 동일하며 지연이 없음 (REST 왕복 100~300ms 생략)
|
||
"""
|
||
if not self._available or not self._connected:
|
||
return None
|
||
with self._cache_lock:
|
||
entry = self._cache.get(code)
|
||
if not entry:
|
||
return None
|
||
age = time.time() - entry.get("ts", 0)
|
||
if age > max_age_sec:
|
||
# 데이터가 너무 오래됨 → REST로 재확인
|
||
return None
|
||
return entry.get("data")
|
||
|
||
@property
|
||
def is_active(self) -> bool:
|
||
"""WebSocket이 연결되어 실시간 데이터를 수신 중이면 True."""
|
||
return bool(self._available and self._connected and self._running)
|
||
|
||
# ==================================================================
|
||
# 내부 메서드
|
||
# ==================================================================
|
||
|
||
def _approval_min_reissue_sec(self) -> float:
|
||
"""KIS access_token/approval REST 재발급 최소 간격(초). 공식: 갱신주기 6시간."""
|
||
return max(0.0, float(get_env_float("KIS_WS_APPROVAL_MIN_REISSUE_SEC", 21600.0)))
|
||
|
||
def _approval_key_age_sec(self) -> float:
|
||
if not self._approval_key_ts:
|
||
return 999999.0
|
||
return max(0.0, time.time() - self._approval_key_ts)
|
||
|
||
def _invalidate_approval_key(self, reason: str = "") -> None:
|
||
"""approval_key 캐시 클리어 — start(force_cleanup) 등 명시적 초기화에만 사용."""
|
||
had = bool(self._approval_key)
|
||
self._approval_key = None
|
||
self._approval_key_ts = 0.0
|
||
if had and reason:
|
||
logger.info("🔑 WebSocket approval_key 무효화 (%s)", reason)
|
||
|
||
def _subscribe_gap_sec(self) -> float:
|
||
"""종목별 H0STCNT0 구독 간격(초). 0에 가까우면 KIS 가 연결 직후 끊을 수 있음."""
|
||
lo = float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MIN_SEC", 0.08))
|
||
hi = float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MAX_SEC", 0.25))
|
||
if hi < lo:
|
||
lo, hi = hi, lo
|
||
if hi <= 0:
|
||
return 0.0
|
||
return random.uniform(lo, hi)
|
||
|
||
def _reconnect_session_wait_sec(self) -> float:
|
||
"""끊긴 직후 재접속 전 대기 — 서버 측 세션 해제 시간 확보."""
|
||
return max(0.0, float(get_env_float("KIS_WS_RECONNECT_SESSION_WAIT_SEC", 2.0)))
|
||
|
||
def _instant_drop_cooldown_sec(self) -> float:
|
||
"""연속 즉시 끊김 후 재시도까지 대기(초). 장중·장외 분리."""
|
||
if not self._is_market_hours():
|
||
return self._seconds_until_market_open()
|
||
return max(
|
||
30.0,
|
||
float(get_env_float("KIS_WS_INSTANT_DROP_COOLDOWN_SEC", 90.0)),
|
||
)
|
||
|
||
def _instant_drop_reason_text(self, streak: int, conn_sec: float) -> str:
|
||
"""즉시 끊김 로그용 — 장외 오진 방지, 실제 의심 원인 명시."""
|
||
parts: list[str] = []
|
||
if not self._is_market_hours():
|
||
parts.append("장외(WS 서비스 시간 외)")
|
||
else:
|
||
parts.append("장중")
|
||
parts.append(f"연속 {streak}회 {conn_sec:.1f}초 내 종료")
|
||
if self._last_subscribe_error:
|
||
parts.append(f"구독오류={self._last_subscribe_error}")
|
||
else:
|
||
parts.append(
|
||
"의심=①access_token 24h 만료·갱신(갱신주기 6h)과 겹침 "
|
||
"②재연결마다 approval_key REST 재요청 "
|
||
"③H0STCNT0 구독 연속 폭주(키움은 간격 있음)"
|
||
)
|
||
return " | ".join(parts)
|
||
|
||
def _get_approval_key(self, *, force_refresh: bool = False) -> Optional[str]:
|
||
"""
|
||
WebSocket 전용 approval_key — kis_approval_manager 파일 캐시 (국내·해외 공유).
|
||
KIS 정책: 24h 유효, 6h 이내 REST 재발급 금지, 재연결 시 동일 키 재사용.
|
||
"""
|
||
try:
|
||
from kis_approval_manager import KISApprovalManager
|
||
except ImportError as exc:
|
||
logger.error("kis_approval_manager import 실패: %s", exc)
|
||
return None
|
||
|
||
mgr = KISApprovalManager.instance(self.is_mock)
|
||
key = mgr.get_approval_key(
|
||
self.app_key,
|
||
self.app_secret,
|
||
self._base_url,
|
||
force_refresh=force_refresh,
|
||
)
|
||
if key:
|
||
self._approval_key = key
|
||
self._approval_key_ts = mgr.issued_ts or time.time()
|
||
return key
|
||
|
||
def _build_sub_payload(self, code: str, subscribe: bool) -> str:
|
||
"""구독(tr_type=1) / 해제(tr_type=2) JSON 메시지 생성."""
|
||
return json.dumps({
|
||
"header": {
|
||
"approval_key": self._approval_key or "",
|
||
"custtype": "P",
|
||
"tr_type": "1" if subscribe else "2",
|
||
"content-type": "utf-8",
|
||
},
|
||
"body": {
|
||
"input": {
|
||
"tr_id": "H0STCNT0",
|
||
"tr_key": code,
|
||
}
|
||
},
|
||
})
|
||
|
||
def _send_sub_msg(self, code: str, subscribe: bool = True) -> None:
|
||
"""WebSocket으로 구독/해제 메시지 전송. 실패 시 조용히 무시."""
|
||
if not self._ws:
|
||
return
|
||
try:
|
||
self._ws.send(self._build_sub_payload(code, subscribe))
|
||
except Exception as e:
|
||
logger.debug("구독 메시지 전송 실패(%s): %s", code, e)
|
||
|
||
def _parse_realtime_msg(self, raw: str) -> None:
|
||
"""
|
||
H0STCNT0 실시간 체결가 메시지 파싱 및 캐시 갱신.
|
||
|
||
정상 포맷: "0|H0STCNT0|001|005930^082317^73900^5^200^0.27^..."
|
||
parts[0]: 암호화구분 (0=평문, 1=암호화)
|
||
parts[1]: TR_ID
|
||
parts[2]: 건수
|
||
parts[3]: 데이터 ('^' 구분)
|
||
|
||
KIS PINGPONG: 메시지가 "PINGPONG" 문자열 → 동일하게 echoing.
|
||
JSON 응답(구독 확인/에러): {"header":{...},"body":{...}} → 무시.
|
||
"""
|
||
if not raw:
|
||
return
|
||
|
||
# ── KIS Application-Level PINGPONG ─────────────────────────
|
||
if raw.strip() == "PINGPONG":
|
||
if self._ws:
|
||
try:
|
||
self._ws.send("PINGPONG")
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
# ── 구독 응답(JSON) ─────────────────────────────────────────
|
||
if raw.startswith("{"):
|
||
try:
|
||
j = json.loads(raw)
|
||
header = j.get("header", {})
|
||
if header.get("tr_id") == "H0STCNT0":
|
||
body = j.get("body", {})
|
||
rt = str(body.get("rt_cd", "") or "").strip()
|
||
msg = str(body.get("msg1", "") or "").strip()
|
||
tr_key = str((body.get("output") or {}).get("tr_key") or "").strip()
|
||
if rt and rt != "0":
|
||
err_line = f"rt_cd={rt} msg={msg}" + (f" code={tr_key}" if tr_key else "")
|
||
self._last_subscribe_error = err_line
|
||
if rt == "1" and "ALREADY IN SUBSCRIBE" in msg.upper():
|
||
logger.debug("H0STCNT0 구독 중복(무해): %s", err_line)
|
||
elif "INVALID APPROVAL" in msg.upper():
|
||
logger.warning("⚠️ H0STCNT0 구독 거부: %s", err_line)
|
||
try:
|
||
from kis_approval_manager import KISApprovalManager
|
||
mgr = KISApprovalManager.instance(self.is_mock)
|
||
reloaded = mgr.reload_from_file()
|
||
if reloaded:
|
||
self._approval_key = reloaded
|
||
self._approval_key_ts = mgr.issued_ts or self._approval_key_ts
|
||
logger.info(
|
||
"🔑 invalid approval → 파일 캐시 동기화 (앞8자: %s…, "
|
||
"6h 미경과 REST 재발급 없음)",
|
||
reloaded[:8],
|
||
)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
logger.warning("⚠️ H0STCNT0 구독 거부: %s", err_line)
|
||
else:
|
||
logger.debug(
|
||
"H0STCNT0 구독 OK: %s",
|
||
tr_key or msg or "SUCCESS",
|
||
)
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
# ── 실시간 데이터 파싱 ──────────────────────────────────────
|
||
parts = raw.split("|")
|
||
if len(parts) < 4:
|
||
return
|
||
# parts[0]=암호화구분, parts[1]=TR_ID, parts[2]=건수, parts[3]=데이터
|
||
if parts[1] != "H0STCNT0":
|
||
return
|
||
|
||
# 암호화된 데이터는 아직 미지원 (평문만 처리)
|
||
if parts[0] == "1":
|
||
logger.debug("H0STCNT0 암호화 데이터 수신 (처리 스킵) → REST fallback 권장")
|
||
return
|
||
|
||
# 한 메시지에 여러 건이 포함될 수 있음 (parts[2] = 건수)
|
||
# 단순히 parts[3] 전체를 파싱 (단건 기준)
|
||
fields = parts[3].split("^")
|
||
if len(fields) <= max(self.IDX_PRICE, self.IDX_CHGPCT):
|
||
return
|
||
|
||
try:
|
||
code = fields[self.IDX_CODE].strip()
|
||
price = float(fields[self.IDX_PRICE])
|
||
if not code or price <= 0:
|
||
return
|
||
|
||
chg_raw = fields[self.IDX_CHANGE] if len(fields) > self.IDX_CHANGE else "0"
|
||
pct_raw = fields[self.IDX_CHGPCT] if len(fields) > self.IDX_CHGPCT else "0.00"
|
||
# 당일 시고저: REST inquire_price 대체용 (매수 체크 시 API 과부하 방지)
|
||
open_raw = fields[self.IDX_OPEN] if len(fields) > self.IDX_OPEN else "0"
|
||
high_raw = fields[self.IDX_HIGH] if len(fields) > self.IDX_HIGH else "0"
|
||
low_raw = fields[self.IDX_LOW] if len(fields) > self.IDX_LOW else "0"
|
||
|
||
# inquire_price output 딕셔너리와 키 이름을 맞춤
|
||
# stck_oprc/hgpr/lwpr 도 함께 저장 → kis_short_ver2 매수 체크에서 REST 없이 활용
|
||
data_compat = {
|
||
"stck_prpr": str(int(price)), # 현재가 (int 문자열)
|
||
"prdy_vrss": chg_raw, # 전일 대비
|
||
"prdy_ctrt": pct_raw, # 전일 대비율
|
||
"stck_oprc": open_raw, # 당일 시가
|
||
"stck_hgpr": high_raw, # 당일 고가
|
||
"stck_lwpr": low_raw, # 당일 저가
|
||
}
|
||
|
||
with self._cache_lock:
|
||
self._cache[code] = {"data": data_compat, "ts": time.time()}
|
||
|
||
# 체결시간·체결량·누적량 (MCP: CNTG_VOL=12, ACML_VOL=13)
|
||
tick_time = fields[self.IDX_TIME].strip() if len(fields) > self.IDX_TIME else ""
|
||
cntg_vol = 0
|
||
acml_vol = 0
|
||
if len(fields) > self.IDX_CNTG_VOL:
|
||
try:
|
||
cntg_vol = int(str(fields[self.IDX_CNTG_VOL]).strip() or "0")
|
||
except ValueError:
|
||
cntg_vol = 0
|
||
if len(fields) > self.IDX_ACML_VOL:
|
||
try:
|
||
acml_vol = int(str(fields[self.IDX_ACML_VOL]).strip() or "0")
|
||
except ValueError:
|
||
acml_vol = 0
|
||
|
||
# ── CandleAggregator: 키움증분 코드는 CNTG, KIS 전용은 ACML 델타
|
||
if self._candle_agg is not None:
|
||
use_inc = False
|
||
try:
|
||
use_inc = bool(self._candle_agg._volume_is_incremental(code))
|
||
except Exception:
|
||
use_inc = False
|
||
agg_vol = cntg_vol if use_inc else acml_vol
|
||
self._candle_agg.on_tick(code, price, agg_vol, tick_time)
|
||
|
||
# TickRecorder: 실매·백테 공통으로 틱당 체결량(CNTG) 저장
|
||
if self._tick_recorder is not None:
|
||
try:
|
||
self._tick_recorder.on_tick(
|
||
code, price, cntg_vol, tick_time, source="kis",
|
||
)
|
||
except Exception as ex:
|
||
logger.debug("H0STCNT0→TickRecorder 실패 %s: %s", code, ex)
|
||
|
||
logger.debug("H0STCNT0 수신: %s → %s원", code, int(price))
|
||
|
||
except (ValueError, IndexError) as e:
|
||
logger.debug("H0STCNT0 파싱 오류: %s | raw=%s", e, raw[:80])
|
||
|
||
# ==================================================================
|
||
# WebSocket 루프 (백그라운드 스레드)
|
||
# ==================================================================
|
||
|
||
def _is_market_hours(self) -> bool:
|
||
"""
|
||
KIS WebSocket 서비스 시간 여부.
|
||
- 실전투자: 08:25~ (장전 호가 포함)
|
||
- 모의투자: 09:00~ (모의 WS 서버가 09:00 이전 연결 즉시 거부)
|
||
False 반환 시 재연결 시도 없이 다음 장 시작까지 대기.
|
||
"""
|
||
import datetime as _dt
|
||
now = _dt.datetime.now()
|
||
if now.weekday() >= 5: # 토·일
|
||
return False
|
||
# 모의투자는 09:00, 실전투자는 08:25 부터 서버 응답
|
||
open_h, open_m = (9, 0) if self.is_mock else (8, 25)
|
||
return _dt.time(open_h, open_m) <= now.time() <= _dt.time(16, 5)
|
||
|
||
def _seconds_until_market_open(self) -> float:
|
||
"""다음 장 시작까지 남은 초. 최소 60초 반환.
|
||
- 실전투자: 08:25 기준
|
||
- 모의투자: 09:00 기준
|
||
"""
|
||
import datetime as _dt
|
||
now = _dt.datetime.now()
|
||
open_h, open_m = (9, 0) if self.is_mock else (8, 25)
|
||
open_t = _dt.time(open_h, open_m)
|
||
close_t = _dt.time(16, 5)
|
||
target = now.replace(hour=open_h, minute=open_m, second=0, microsecond=0)
|
||
if now.weekday() >= 5:
|
||
target += _dt.timedelta(days=1)
|
||
elif now.time() >= close_t:
|
||
target += _dt.timedelta(days=1)
|
||
elif now.time() >= open_t:
|
||
# 장중 — '다음 장 시작'이 아니라 호출부에서 cooldown 사용
|
||
return max(
|
||
30.0,
|
||
float(get_env_float("KIS_WS_INSTANT_DROP_COOLDOWN_SEC", 90.0)),
|
||
)
|
||
# 주말 건너뛰기
|
||
while target.weekday() >= 5:
|
||
target += _dt.timedelta(days=1)
|
||
return max(60.0, (target - now).total_seconds())
|
||
|
||
def _run_ws_loop(self) -> None:
|
||
"""
|
||
재연결 정책을 포함한 WebSocket 메인 루프.
|
||
run_forever() 가 반환(연결 끊김)되면 지수 백오프 후 재연결 시도.
|
||
KIS 정책: 1시간 내 MAX_RECONNECTS_PER_HOUR 회 초과 시 강제 대기.
|
||
장외 시간(16:05~08:25, 주말): 재연결 없이 다음 장까지 대기.
|
||
"""
|
||
# 즉시 끊김 감지 — env 로 조정 (기본: 3초 이내 × 3회)
|
||
INSTANT_DROP_SEC = float(get_env_float("KIS_WS_INSTANT_DROP_SEC", 3.0))
|
||
INSTANT_DROP_MAX = int(get_env_int("KIS_WS_INSTANT_DROP_MAX", 3))
|
||
_instant_drop_streak = 0
|
||
_last_conn_duration = 0.0
|
||
|
||
while self._running:
|
||
now = time.time()
|
||
|
||
# ── 장외 시간이면 다음 장 시작까지 대기 ─────────────────────
|
||
if not self._is_market_hours():
|
||
wait_sec = self._seconds_until_market_open()
|
||
logger.info(
|
||
"🌙 장외 시간 — WebSocket 재연결 중지, 다음 장 시작까지 %.0f분 대기",
|
||
wait_sec / 60,
|
||
)
|
||
# 재연결 카운터·백오프 초기화 (장 시작 시 깨끗하게 재접속)
|
||
self._reconnect_count = 0
|
||
self._reconnect_times = []
|
||
self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC
|
||
_instant_drop_streak = 0
|
||
# 60초 단위로 쪼개서 슬립 (stop() 신호 빠르게 감지)
|
||
for _ in range(int(wait_sec // 60)):
|
||
if not self._running:
|
||
return
|
||
time.sleep(60)
|
||
time.sleep(wait_sec % 60)
|
||
continue
|
||
|
||
# ── 연속 즉시 종료 → 쿨다운 (장외/장중 메시지 분리) ────────
|
||
if _instant_drop_streak >= INSTANT_DROP_MAX:
|
||
wait_sec = self._instant_drop_cooldown_sec()
|
||
reason = self._instant_drop_reason_text(
|
||
_instant_drop_streak, _last_conn_duration,
|
||
)
|
||
if not self._is_market_hours():
|
||
logger.warning(
|
||
"⚠️ KIS WebSocket 연속 즉시 끊김 %d회 — %s "
|
||
"→ 다음 장 시작까지 %.0f분 대기",
|
||
_instant_drop_streak, reason, wait_sec / 60,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"⚠️ KIS WebSocket 연속 즉시 끊김 %d회 — %s "
|
||
"→ %.0f초 쿨다운 후 **기존 approval_key** 로 재접속 "
|
||
"(6h 이내 REST 재발급 금지, 구독 간격 적용)",
|
||
_instant_drop_streak, reason, wait_sec,
|
||
)
|
||
self._reconnect_count = 0
|
||
self._reconnect_times = []
|
||
self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC
|
||
_instant_drop_streak = 0
|
||
self._last_subscribe_error = ""
|
||
for _ in range(int(wait_sec // 60)):
|
||
if not self._running:
|
||
return
|
||
time.sleep(60)
|
||
time.sleep(wait_sec % 60)
|
||
continue
|
||
|
||
# ── 안정 연결 후 끊김이면 재연결 카운터 초기화 ───────────
|
||
# STABLE_CONN_RESET_SEC(5분) 이상 정상 운영 후 끊겼다면
|
||
# 이전 재연결 이력을 소거하여 'KIS 정상 케이스'로 처리.
|
||
# (수개월 운영 중 산발적 네트워크 단절에 의해 10회 소진 → 영구 비활성화 방지)
|
||
if (
|
||
self._last_connect_time > 0
|
||
and (now - self._last_connect_time) > self.STABLE_CONN_RESET_SEC
|
||
and self._reconnect_count > 0
|
||
):
|
||
logger.info(
|
||
"♻️ WebSocket 안정 운영(%.0f분) 후 끊김 → 재연결 카운터 초기화 (%d→0)",
|
||
(now - self._last_connect_time) / 60,
|
||
self._reconnect_count,
|
||
)
|
||
self._reconnect_count = 0
|
||
self._reconnect_times = []
|
||
self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC
|
||
|
||
# ── 1시간 내 재연결 횟수 점검 ────────────────────────────
|
||
self._reconnect_times = [t for t in self._reconnect_times if now - t < 3600]
|
||
|
||
if len(self._reconnect_times) >= self.MAX_RECONNECTS_PER_HOUR:
|
||
# 가장 오래된 재연결로부터 1시간이 지날 때까지 대기
|
||
wait = max(10, 3600 - (now - self._reconnect_times[0]) + 10)
|
||
logger.warning(
|
||
"⛔ 1시간 내 WebSocket 재연결 %d회 초과 → %.0f초(%.0f분) 강제 대기 (KIS 차단 방지)",
|
||
self.MAX_RECONNECTS_PER_HOUR, wait, wait / 60,
|
||
)
|
||
time.sleep(wait)
|
||
continue
|
||
|
||
# ── 총 재연결 횟수 초과 → REST fallback 전환 ─────────────
|
||
if self._reconnect_count >= self.MAX_RECONNECT_ATTEMPTS:
|
||
logger.error(
|
||
"❌ WebSocket 최대 재연결 %d회 초과 → WebSocket 종료, REST fallback 전환",
|
||
self.MAX_RECONNECT_ATTEMPTS,
|
||
)
|
||
self._running = False
|
||
break
|
||
|
||
# ── approval_key — 6h 이내 REST 재발급 금지, 재연결은 캐시 키 재사용 ──
|
||
if self._reconnect_count > 0:
|
||
sess_wait = self._reconnect_session_wait_sec()
|
||
if sess_wait > 0:
|
||
logger.debug(
|
||
"⏳ WS 재접속 전 세션 대기 %.1fs (approval_key age=%.0f분)",
|
||
sess_wait, self._approval_key_age_sec() / 60,
|
||
)
|
||
time.sleep(sess_wait)
|
||
approval_key = self._get_approval_key(
|
||
force_refresh=get_env_bool("KIS_WS_RECONNECT_REFRESH_KEY", False),
|
||
)
|
||
if not approval_key:
|
||
logger.warning(
|
||
"WebSocket approval_key 발급 불가 → %ds 대기 후 재시도",
|
||
int(self._reconnect_delay),
|
||
)
|
||
time.sleep(self._reconnect_delay)
|
||
self._reconnect_delay = min(
|
||
self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC
|
||
)
|
||
continue
|
||
|
||
# ── 재연결 카운트 및 대기 ─────────────────────────────────
|
||
self._reconnect_count += 1
|
||
if self._reconnect_count > 1:
|
||
self._reconnect_times.append(time.time())
|
||
logger.info(
|
||
"🔄 WebSocket 재연결 시도 %d/%d (대기 %ds 완료)",
|
||
self._reconnect_count,
|
||
self.MAX_RECONNECT_ATTEMPTS,
|
||
int(self._reconnect_delay),
|
||
)
|
||
time.sleep(self._reconnect_delay)
|
||
self._reconnect_delay = min(
|
||
self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC
|
||
)
|
||
|
||
# ── WebSocketApp 생성 및 실행 ─────────────────────────────
|
||
_conn_start = time.time() # 연결 시작 시각 (즉시 종료 감지용)
|
||
try:
|
||
ws_app = self._ws_lib.WebSocketApp(
|
||
self._ws_url,
|
||
on_open = self._on_open,
|
||
on_message = self._on_message,
|
||
on_error = self._on_error,
|
||
on_close = self._on_close,
|
||
)
|
||
self._ws = ws_app
|
||
# ping_interval: WebSocket 프로토콜 레벨 PING (연결 유지)
|
||
# KIS Application-Level PINGPONG은 _parse_realtime_msg 에서 처리
|
||
ws_app.run_forever(ping_interval=20, ping_timeout=10)
|
||
except Exception as e:
|
||
logger.error("WebSocket run_forever 예외: %s", e)
|
||
|
||
if not self._running:
|
||
break
|
||
|
||
# ── 즉시 종료 여부 판정 ───────────────────────────────────────
|
||
_conn_duration = time.time() - _conn_start
|
||
_last_conn_duration = _conn_duration
|
||
if _conn_duration < INSTANT_DROP_SEC:
|
||
_instant_drop_streak += 1
|
||
logger.debug(
|
||
"⚡ WebSocket 즉시 종료 감지 (%.1f초, streak=%d/%d)",
|
||
_conn_duration, _instant_drop_streak, INSTANT_DROP_MAX,
|
||
)
|
||
else:
|
||
# 어느 정도 연결 유지됐다면 즉시 종료 streak 초기화
|
||
_instant_drop_streak = 0
|
||
|
||
logger.info("KIS WebSocket 루프 종료 (is_active=False, REST fallback 전환)")
|
||
|
||
# ------------------------------------------------------------------
|
||
# WebSocket 콜백
|
||
# ------------------------------------------------------------------
|
||
|
||
def _on_open(self, ws) -> None:
|
||
"""연결 성공: 등록된 모든 종목 구독 (간격 두고 1회만 — 이중 전송 금지)."""
|
||
self._connected = True
|
||
self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC
|
||
self._last_connect_time = time.time()
|
||
self._last_subscribe_error = ""
|
||
|
||
logger.info(
|
||
"✅ KIS WebSocket 연결 성공 (H0STCNT0 | url=%s | approval_age=%.0f분)",
|
||
self._ws_url, self._approval_key_age_sec() / 60,
|
||
)
|
||
|
||
with self._sub_lock:
|
||
for code in sorted(self._permanent_codes):
|
||
if code not in self._subscribed:
|
||
if len(self._subscribed) >= self.MAX_SUBSCRIPTIONS:
|
||
logger.warning("⚠️ 구독 한도로 영구구독 추가 불가: %s", code)
|
||
break
|
||
self._subscribed.add(code)
|
||
codes = sorted(self._subscribed)
|
||
|
||
def _subscribe_all() -> None:
|
||
for i, code in enumerate(codes):
|
||
if i > 0:
|
||
gap = self._subscribe_gap_sec()
|
||
if gap > 0:
|
||
time.sleep(gap)
|
||
self._send_sub_msg(code, subscribe=True)
|
||
if codes:
|
||
logger.info(
|
||
"📡 WebSocket 구독 일괄 등록: %s (%d종목, gap=%.2f~%.2fs)",
|
||
", ".join(codes), len(codes),
|
||
float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MIN_SEC", 0.08)),
|
||
float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MAX_SEC", 0.25)),
|
||
)
|
||
|
||
if codes:
|
||
threading.Thread(
|
||
target=_subscribe_all, daemon=True, name="KIS-WS-Sub",
|
||
).start()
|
||
|
||
# ── 연결 성공 시 갭보정 콜백 (장 시간일 때만) ───────────────
|
||
# 봇 시작 후 처음 WS가 안정 연결되는 시점(9:00 이후)에
|
||
# REST 분봉 데이터로 DB를 채워 '봉부족' 문제 해소.
|
||
# 새벽 재연결 시에는 is_market_hours() False → 콜백 스킵.
|
||
if self._on_connected_callback and self._is_market_hours():
|
||
try:
|
||
cb_thread = threading.Thread(
|
||
target=self._on_connected_callback,
|
||
name="WS-GapFill",
|
||
daemon=True,
|
||
)
|
||
cb_thread.start()
|
||
except Exception as e:
|
||
logger.debug("갭보정 콜백 실행 실패: %s", e)
|
||
|
||
def _on_message(self, ws, message: str) -> None:
|
||
self._parse_realtime_msg(message)
|
||
|
||
def _on_error(self, ws, error) -> None:
|
||
self._connected = False
|
||
logger.warning("⚠️ KIS WebSocket 오류: %s", error)
|
||
|
||
def _on_close(self, ws, close_status_code, close_msg) -> None:
|
||
self._connected = False
|
||
logger.info(
|
||
"🔌 KIS WebSocket 연결 종료 (code=%s msg=%s)",
|
||
close_status_code, close_msg or "",
|
||
)
|
||
|
||
|
||
# ======================================================================
|
||
# ======================================================================
|
||
# CandleAggregator — WebSocket 틱 → OHLCV 봉 실시간 집계기
|
||
# ======================================================================
|
||
class CandleAggregator:
|
||
"""
|
||
KISWebSocketPriceCache 에서 수신한 틱을 N분봉으로 집계합니다.
|
||
|
||
Two-Track 아키텍처 (Gemini/퀀트 펌 방식)
|
||
─────────────────────────────────────────
|
||
[트랙 1 — 매매 두뇌 (논블로킹)]
|
||
WebSocket 틱 → on_tick() → RAM에서 OHLCV 즉시 갱신
|
||
→ 매수/매도 판단은 get_latest_confirmed() 등 메모리 접근만 사용
|
||
→ DB 대기 시간 0ms, 타점 놓침 없음
|
||
|
||
[트랙 2 — 기록원 스레드 (백그라운드)]
|
||
봉 확정 시 dict를 Queue에 put_nowait() (논블로킹, 0.000001초)
|
||
→ 백그라운드 스레드(_db_writer)가 BATCH_SIZE개 or FLUSH_INTERVAL초마다
|
||
DB에 executemany() 한 방에 묶어서 INSERT
|
||
→ 백테스트용 봉 데이터 완전 보존, 매매 루프 블로킹 없음
|
||
|
||
진행 중 봉(is_confirmed=0) 처리
|
||
─────────────────────────────────
|
||
- RAM의 _current 버퍼에만 존재 → DB에 절대 쓰지 않음
|
||
- 매수 루프는 get_current_candle() 로 즉시 메모리 접근
|
||
- 봉 확정(분 바뀜) 순간에만 Queue → DB 기록
|
||
|
||
RSI 계산
|
||
────────
|
||
- 확정 봉 close 리스트(_closes, 최대 MAX_CLOSE_BUFFER개) RAM에 유지
|
||
- RSI(2/3/5) 계산은 순수 Python 연산, DB 조회 없음
|
||
- 봉 수 부족 시 RSI=None → 매수 루프에서 신호 무시
|
||
|
||
스레드 안전성
|
||
─────────────
|
||
- _lock : on_tick / fill_gap 간 경합 방지 (RAM 버퍼 보호)
|
||
- Queue : thread-safe, put_nowait 는 lock 불필요
|
||
- _db_writer : 독립 daemon 스레드 (봇 종료 시 자동 소멸)
|
||
"""
|
||
|
||
MAX_CLOSE_BUFFER = 200 # RSI 계산용 close 보관 기본값 (WS_CANDLE_RAM_BUFFER 로 덮어씀)
|
||
BATCH_SIZE = 50 # 이 개수 이상 쌓이면 즉시 배치 플러시
|
||
FLUSH_INTERVAL = 2.0 # 초 — BATCH_SIZE 미달이라도 이 주기로 플러시
|
||
|
||
def __init__(self, db=None, timeframes: list = None):
|
||
"""
|
||
Args:
|
||
db : TradeDB 인스턴스. None이면 DB 쓰기 비활성(순수 RAM 모드).
|
||
timeframes: 집계할 봉 단위 리스트 (기본 [1, 3])
|
||
"""
|
||
self.db = db
|
||
self.timeframes: list = timeframes if timeframes else [1, 3]
|
||
# MOMENTUM 전일시가(E) 등 — RAM에 최소 2영업일 1분봉 보관 (DB 병합 없이 키움 REST 갭보정)
|
||
self._ram_buffer_max = max(
|
||
self.MAX_CLOSE_BUFFER,
|
||
get_env_int("WS_CANDLE_RAM_BUFFER", 500),
|
||
)
|
||
self._lock = threading.Lock()
|
||
|
||
# ── 트랙 1: RAM 버퍼 ─────────────────────────────────────
|
||
# 진행 중인 봉: { (code, tf): {candle_time, open, high, low, close, volume} }
|
||
self._current: Dict[tuple, dict] = {}
|
||
# RSI 계산용 확정 봉 close 가격 히스토리: { (code, tf): [c1, c2, ...] }
|
||
self._closes: Dict[tuple, list] = {}
|
||
# 최근 확정 봉 보관 (get_latest_confirmed / get_candles 용): { (code, tf): [dict, ...] }
|
||
self._confirmed: Dict[tuple, list] = {}
|
||
|
||
# ── 트랙 2: 비동기 DB 배치 큐 ────────────────────────────
|
||
# 봉 확정 시 dict를 여기 넣으면 _db_writer 가 배치로 INSERT
|
||
self._write_queue: queue.Queue = queue.Queue(maxsize=10000)
|
||
self._db_writer_thread: Optional[threading.Thread] = None
|
||
# 보유 중 트레일 고점: code → float (SHORT 봇이 set_peak_provider 로 주입)
|
||
self._peak_provider: Optional[Any] = None
|
||
# 키움 WS 틱 체결량(FID 15) → 분봉 volume 합산. 미등록 종목은 KIS 누적거래량 델타.
|
||
self._incremental_vol_codes: Set[str] = set()
|
||
if self.db is not None:
|
||
self._start_db_writer()
|
||
|
||
logger.info("✅ CandleAggregator 초기화 완료 (timeframes=%s, db=%s)",
|
||
self.timeframes, "활성" if self.db else "비활성(RAM 전용)")
|
||
|
||
def set_peak_provider(self, provider) -> None:
|
||
"""보유 종목의 트레일링 고점 조회 — 봉 확정 시 ws_candles.holding_peak 저장용."""
|
||
self._peak_provider = provider
|
||
|
||
def set_incremental_volume_codes(self, codes: Optional[Set[str]]) -> None:
|
||
"""키움 WS 후보 종목 — 틱별 체결량을 분봉 volume 에 누적(+=)."""
|
||
with self._lock:
|
||
if codes is None:
|
||
self._incremental_vol_codes = set()
|
||
else:
|
||
self._incremental_vol_codes = {str(c).strip() for c in codes if c}
|
||
|
||
def _volume_is_incremental(self, code: str) -> bool:
|
||
return str(code).strip() in self._incremental_vol_codes
|
||
|
||
def _holding_peak_for(self, code: str) -> Optional[float]:
|
||
if not self._peak_provider:
|
||
return None
|
||
try:
|
||
v = self._peak_provider(code)
|
||
return float(v) if v and float(v) > 0 else None
|
||
except Exception:
|
||
return None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 트랙 2: 백그라운드 DB 배치 기록원
|
||
# ------------------------------------------------------------------
|
||
|
||
def _start_db_writer(self) -> None:
|
||
"""백그라운드 DB 배치 기록 스레드를 시작합니다."""
|
||
self._db_writer_thread = threading.Thread(
|
||
target=self._db_writer_loop,
|
||
name="CandleDBWriter",
|
||
daemon=True, # 봇 종료 시 자동 소멸
|
||
)
|
||
self._db_writer_thread.start()
|
||
logger.info("✅ CandleAggregator DB 기록원 스레드 시작 (배치=%d, 주기=%.1fs)",
|
||
self.BATCH_SIZE, self.FLUSH_INTERVAL)
|
||
|
||
def _db_writer_loop(self) -> None:
|
||
"""
|
||
배치 기록 루프.
|
||
- Queue에서 BATCH_SIZE개 모이면 즉시 배치 INSERT
|
||
- BATCH_SIZE 미달이라도 FLUSH_INTERVAL초마다 플러시
|
||
- 겸사겸사 봉주기 경과 후 미확정 상태로 방치된 저유동 종목 봉도
|
||
같은 주기로 강제확정 점검 (flush_stale_current_candles)
|
||
"""
|
||
batch: list = []
|
||
last_flush = time.time()
|
||
last_stale_check = 0.0
|
||
stale_check_interval = float(
|
||
get_env_int("WS_CANDLE_STALE_CHECK_INTERVAL_SEC", 2)
|
||
)
|
||
|
||
while True:
|
||
try:
|
||
# FLUSH_INTERVAL 내에서 최대한 많이 모아 배치 구성
|
||
timeout = max(0.1, self.FLUSH_INTERVAL - (time.time() - last_flush))
|
||
item = self._write_queue.get(timeout=timeout)
|
||
if item is None:
|
||
# None = 종료 신호
|
||
break
|
||
batch.append(item)
|
||
self._write_queue.task_done()
|
||
except queue.Empty:
|
||
pass
|
||
|
||
now = time.time()
|
||
should_flush = len(batch) >= self.BATCH_SIZE or \
|
||
(batch and (now - last_flush) >= self.FLUSH_INTERVAL)
|
||
|
||
if should_flush and batch:
|
||
self._flush_batch(batch)
|
||
batch = []
|
||
last_flush = now
|
||
|
||
if now - last_stale_check >= stale_check_interval:
|
||
last_stale_check = now
|
||
try:
|
||
self.flush_stale_current_candles()
|
||
except Exception as e:
|
||
logger.debug("flush_stale_current_candles 실패(무시): %s", e)
|
||
|
||
# 루프 종료 시 남은 배치 처리
|
||
if batch:
|
||
self._flush_batch(batch)
|
||
logger.info("CandleDBWriter 스레드 종료")
|
||
|
||
def _flush_batch(self, batch: list) -> None:
|
||
"""
|
||
배치 리스트를 DB에 한 번의 executemany 로 INSERT.
|
||
각 item: {code, tf, candle_time, open, high, low, close, volume,
|
||
is_confirmed, source, rsi_2, rsi_3, rsi_5}
|
||
"""
|
||
if not self.db or not batch:
|
||
return
|
||
try:
|
||
import datetime as _dt
|
||
now_str = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
rows = []
|
||
for item in batch:
|
||
rows.append((
|
||
item["code"],
|
||
item["tf"],
|
||
item["candle_time"],
|
||
item["open"],
|
||
item["high"],
|
||
item["low"],
|
||
item["close"],
|
||
item.get("volume", 0),
|
||
item.get("rsi_2"),
|
||
item.get("rsi_3"),
|
||
item.get("rsi_5"),
|
||
item.get("is_confirmed", 1),
|
||
item.get("source", "ws"),
|
||
item.get("holding_peak"),
|
||
now_str,
|
||
))
|
||
# ws_candles 테이블이 존재하면 배치 INSERT (없으면 조용히 skip)
|
||
self.db.conn.execute(
|
||
"""
|
||
INSERT INTO ws_candles
|
||
(code, timeframe, candle_time, `open`, high, low, close,
|
||
volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, updated_at)
|
||
VALUES
|
||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
ON DUPLICATE KEY UPDATE
|
||
`open`=VALUES(`open`), high=GREATEST(high, VALUES(high)),
|
||
low=VALUES(low), close=VALUES(close), volume=VALUES(volume),
|
||
rsi_2=VALUES(rsi_2), rsi_3=VALUES(rsi_3), rsi_5=VALUES(rsi_5),
|
||
is_confirmed=VALUES(is_confirmed),
|
||
holding_peak=GREATEST(COALESCE(holding_peak,0), COALESCE(VALUES(holding_peak),0)),
|
||
updated_at=VALUES(updated_at)
|
||
""",
|
||
rows[0],
|
||
) if len(rows) == 1 else self._executemany_batch(rows)
|
||
logger.debug("💾 [배치저장] %d봉 → DB", len(rows))
|
||
except Exception as e:
|
||
logger.debug("_flush_batch 실패 (무시): %s", e)
|
||
|
||
def _executemany_batch(self, rows: list) -> None:
|
||
"""여러 봉을 executemany 로 한 번에 INSERT."""
|
||
sql = """
|
||
INSERT INTO ws_candles
|
||
(code, timeframe, candle_time, `open`, high, low, close,
|
||
volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, holding_peak, updated_at)
|
||
VALUES
|
||
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
ON DUPLICATE KEY UPDATE
|
||
`open`=VALUES(`open`), high=GREATEST(high, VALUES(high)),
|
||
low=VALUES(low), close=VALUES(close), volume=VALUES(volume),
|
||
rsi_2=VALUES(rsi_2), rsi_3=VALUES(rsi_3), rsi_5=VALUES(rsi_5),
|
||
is_confirmed=VALUES(is_confirmed),
|
||
holding_peak=GREATEST(COALESCE(holding_peak,0), COALESCE(VALUES(holding_peak),0)),
|
||
updated_at=VALUES(updated_at)
|
||
"""
|
||
# pymysql executemany: cursor.executemany(sql, list_of_tuples)
|
||
import pymysql
|
||
with self.db.conn._lock:
|
||
self.db.conn._ensure_connected()
|
||
cur = self.db.conn._conn.cursor()
|
||
cur.executemany(sql, rows)
|
||
|
||
def stop(self) -> None:
|
||
"""기록원 스레드를 안전하게 종료합니다."""
|
||
if self._db_writer_thread and self._db_writer_thread.is_alive():
|
||
self._write_queue.put(None) # 종료 신호
|
||
self._db_writer_thread.join(timeout=5)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 시각 유틸
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _candle_key(tick_time_str: str, timeframe: int) -> str:
|
||
"""
|
||
틱 시각(HHMMSS)과 timeframe(분)으로 봉 시작 시각 키를 반환.
|
||
날짜는 오늘 날짜를 사용 (장 중에만 동작하므로 안전).
|
||
반환 형식: YYYYMMDDHHMM (분 단위, 초 버림)
|
||
|
||
예) tick_time="091523", timeframe=3 → "202603020915" (09:15봉)
|
||
"""
|
||
import datetime as _dt
|
||
try:
|
||
hh = int(tick_time_str[0:2]) if len(tick_time_str) >= 6 else 0
|
||
mm = int(tick_time_str[2:4]) if len(tick_time_str) >= 6 else 0
|
||
# timeframe 단위로 내림: 3분봉이면 09:17 → 09:15
|
||
floored_mm = (mm // timeframe) * timeframe
|
||
today = _dt.date.today().strftime("%Y%m%d")
|
||
return f"{today}{hh:02d}{floored_mm:02d}"
|
||
except Exception:
|
||
import datetime as _dt
|
||
return _dt.datetime.now().strftime("%Y%m%d%H%M")
|
||
|
||
# ------------------------------------------------------------------
|
||
# RSI 계산 (확정 봉 close 리스트 기반)
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _calc_rsi(closes: list, period: int) -> Optional[float]:
|
||
"""
|
||
close 가격 리스트에서 RSI(period) 계산.
|
||
- Wilder's smoothed moving average (단순 rolling mean 사용)
|
||
- 데이터 부족 시 None 반환
|
||
"""
|
||
if len(closes) < period + 1:
|
||
return None
|
||
recent = closes[-(period + 1):]
|
||
gains, losses = [], []
|
||
for i in range(1, len(recent)):
|
||
delta = recent[i] - recent[i - 1]
|
||
gains.append(max(delta, 0))
|
||
losses.append(max(-delta, 0))
|
||
avg_gain = sum(gains) / period if period > 0 else 0
|
||
avg_loss = sum(losses) / period if period > 0 else 0
|
||
if avg_loss == 0:
|
||
return 100.0
|
||
rs = avg_gain / avg_loss
|
||
return round(100 - (100 / (1 + rs)), 2)
|
||
|
||
def _compute_rsi_set(self, closes: list) -> tuple:
|
||
"""RSI 2/3/5 를 한 번에 계산해서 (rsi2, rsi3, rsi5) 반환."""
|
||
return (
|
||
self._calc_rsi(closes, 2),
|
||
self._calc_rsi(closes, 3),
|
||
self._calc_rsi(closes, 5),
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 핵심: 틱 수신 처리
|
||
# ------------------------------------------------------------------
|
||
|
||
def on_tick(self, code: str, price: float, volume: int, tick_time: str) -> None:
|
||
"""
|
||
KISWebSocketPriceCache._parse_realtime_msg 에서 틱마다 호출됨.
|
||
모든 timeframe 에 대해 봉 갱신/확정 처리.
|
||
"""
|
||
if price <= 0:
|
||
return
|
||
with self._lock:
|
||
for tf in self.timeframes:
|
||
self._process_tick(code, price, volume, tick_time, tf)
|
||
|
||
def _process_tick(self, code: str, price: float, volume: int,
|
||
tick_time: str, tf: int) -> None:
|
||
"""
|
||
단일 timeframe 에 대한 틱 처리 (lock 내부에서 호출).
|
||
|
||
[트랙 1] RAM 갱신만 수행, DB 호출 없음 → 블로킹 0ms
|
||
[트랙 2] 봉 확정 순간에만 Queue.put_nowait() → 기록원이 비동기 배치 저장
|
||
"""
|
||
key = (code, tf)
|
||
new_ctime = self._candle_key(tick_time, tf)
|
||
|
||
if key not in self._current:
|
||
# 첫 틱: 새 봉 시작 (RAM만)
|
||
inc = self._volume_is_incremental(code)
|
||
self._current[key] = {
|
||
"candle_time": new_ctime,
|
||
"open": price, "high": price, "low": price,
|
||
"close": price,
|
||
"volume": int(volume) if inc else 0,
|
||
"_acml_base": None if inc else int(volume),
|
||
}
|
||
return
|
||
|
||
cur = self._current[key]
|
||
|
||
if new_ctime != cur["candle_time"]:
|
||
# ── 봉 확정 (다음 틱 도착 → 정상 롤오버) ────────────────
|
||
confirmed_candle = self._confirm_current_bucket(key, cur)
|
||
logger.debug(
|
||
"🕯 [봉확정] %s %dM %s C=%.0f RSI3=%s",
|
||
code, tf, confirmed_candle["candle_time"], confirmed_candle["close"],
|
||
f"{confirmed_candle['rsi_3']:.1f}" if confirmed_candle["rsi_3"] is not None else "N/A",
|
||
)
|
||
|
||
# ── 새 봉 시작 (RAM만) ──────────────────────────────────
|
||
inc = self._volume_is_incremental(code)
|
||
self._current[key] = {
|
||
"candle_time": new_ctime,
|
||
"open": price, "high": price, "low": price,
|
||
"close": price,
|
||
"volume": int(volume) if inc else 0,
|
||
"_acml_base": None if inc else int(volume),
|
||
}
|
||
else:
|
||
# 같은 봉: OHLCV 갱신 (RAM만, DB 쓰기 없음)
|
||
cur["high"] = max(cur["high"], price)
|
||
cur["low"] = min(cur["low"], price)
|
||
cur["close"] = price
|
||
if self._volume_is_incremental(code):
|
||
# 키움 FID 15: 틱 체결량 합산
|
||
cur["volume"] = int(cur.get("volume", 0) or 0) + int(volume or 0)
|
||
else:
|
||
# KIS ACML_VOL: 봉 시작 대비 델타
|
||
base = int(cur.get("_acml_base", 0) or 0)
|
||
cur["volume"] = max(0, int(volume or 0) - base)
|
||
|
||
def _confirm_current_bucket(self, key: tuple, cur: Dict) -> Dict:
|
||
"""
|
||
진행 중이던 ``_current[key]`` 봉을 확정봉으로 전환한다.
|
||
(``_process_tick``의 정상 롤오버 / ``flush_stale_current_candles``의
|
||
시간경과 강제확정 양쪽에서 공통으로 사용 — 락 보유 상태에서 호출)
|
||
|
||
[트랙 1] RSI 계산 + ``_confirmed`` RAM 버퍼 적재 (매수 루프 즉시 참조용)
|
||
[트랙 2] DB 기록 Queue 적재 (논블로킹)
|
||
|
||
동일 ``candle_time`` 이 이미 있으면 **append 하지 않고 upsert**.
|
||
volume 은 더 큰 쪽을 유지 (갭보정 불완전봉 < WS 누적 < REST 완전봉).
|
||
"""
|
||
code, tf = key
|
||
ctime = str(cur.get("candle_time") or "")[:12]
|
||
buf = self._confirmed.setdefault(key, [])
|
||
closes = self._closes.setdefault(key, [])
|
||
|
||
new_vol = int(cur.get("volume") or 0)
|
||
idx = next(
|
||
(
|
||
i for i, c in enumerate(buf)
|
||
if str(c.get("candle_time") or "")[:12] == ctime
|
||
),
|
||
-1,
|
||
)
|
||
|
||
hp = self._holding_peak_for(code)
|
||
if idx >= 0:
|
||
old = buf[idx]
|
||
old_vol = int(old.get("volume") or 0)
|
||
# 이미 더 완전한 volume 이 있으면(예: REST 완전봉) WS 부분봉으로 덮지 않음
|
||
if new_vol < old_vol:
|
||
confirmed_candle = dict(old)
|
||
confirmed_candle["is_confirmed"] = 1
|
||
if hp is not None:
|
||
confirmed_candle["holding_peak"] = max(
|
||
float(confirmed_candle.get("holding_peak") or 0), float(hp),
|
||
)
|
||
confirmed_candle["high"] = max(
|
||
float(confirmed_candle.get("high") or 0), float(hp),
|
||
)
|
||
return confirmed_candle
|
||
|
||
low_cands = [
|
||
x for x in (float(old.get("low") or 0), float(cur["low"])) if x > 0
|
||
]
|
||
confirmed_candle = {
|
||
"code": code,
|
||
"tf": tf,
|
||
"candle_time": ctime,
|
||
"open": float(old.get("open") or cur["open"]),
|
||
"high": max(float(old.get("high") or 0), float(cur["high"])),
|
||
"low": min(low_cands) if low_cands else float(cur["low"]),
|
||
"close": float(cur["close"]),
|
||
"volume": max(old_vol, new_vol),
|
||
"is_confirmed": 1,
|
||
"source": str(cur.get("source") or old.get("source") or "ws"),
|
||
}
|
||
if hp is not None:
|
||
confirmed_candle["holding_peak"] = hp
|
||
confirmed_candle["high"] = max(
|
||
float(confirmed_candle["high"]), float(hp),
|
||
)
|
||
buf[idx] = confirmed_candle
|
||
closes[:] = [float(c.get("close") or 0) for c in buf]
|
||
rsi2, rsi3, rsi5 = self._compute_rsi_set(closes[: idx + 1])
|
||
confirmed_candle["rsi_2"] = rsi2
|
||
confirmed_candle["rsi_3"] = rsi3
|
||
confirmed_candle["rsi_5"] = rsi5
|
||
buf[idx] = confirmed_candle
|
||
else:
|
||
closes.append(float(cur["close"]))
|
||
if len(closes) > self._ram_buffer_max:
|
||
closes.pop(0)
|
||
rsi2, rsi3, rsi5 = self._compute_rsi_set(closes)
|
||
confirmed_candle = {
|
||
"code": code,
|
||
"tf": tf,
|
||
"candle_time": ctime,
|
||
"open": cur["open"],
|
||
"high": cur["high"],
|
||
"low": cur["low"],
|
||
"close": cur["close"],
|
||
"volume": new_vol,
|
||
"rsi_2": rsi2,
|
||
"rsi_3": rsi3,
|
||
"rsi_5": rsi5,
|
||
"is_confirmed": 1,
|
||
"source": "ws",
|
||
}
|
||
if hp is not None:
|
||
confirmed_candle["holding_peak"] = hp
|
||
confirmed_candle["high"] = max(cur["high"], hp)
|
||
buf.append(confirmed_candle)
|
||
if len(buf) > self._ram_buffer_max:
|
||
buf.pop(0)
|
||
closes[:] = [float(c.get("close") or 0) for c in buf]
|
||
|
||
try:
|
||
self._write_queue.put_nowait(confirmed_candle)
|
||
except queue.Full:
|
||
logger.warning("⚠️ CandleAggregator 쓰기 Queue 가득참 — 봉 1개 DROP (코드: %s)", code)
|
||
|
||
return confirmed_candle
|
||
|
||
# ------------------------------------------------------------------
|
||
# 저유동 종목 봉 강제확정: 다음 체결 틱이 안 와도 봉주기 경과 시 확정
|
||
# ------------------------------------------------------------------
|
||
def flush_stale_current_candles(self) -> int:
|
||
"""
|
||
``_current``(진행 중 봉)가 자기 봉주기(tf분)를 이미 지났는데도
|
||
다음 체결 틱이 없어 확정되지 못한 채 머물러 있으면, 그 시점까지
|
||
쌓인 데이터로 강제 확정한다.
|
||
|
||
배경: 기존엔 "다음 틱이 와야 봉 확정"이라, 저유동 종목이 한동안
|
||
무거래면 이미 끝난 봉도 다음 체결 전까지 무한정 미확정 상태로
|
||
남아 매수 신호 인식이 그만큼 밀렸다(2026-07-08 원티드랩 14분 지연
|
||
사례 — 3분봉인데 다음 틱이 14분 뒤에야 와서 신호가 14분 늦게 잡힘).
|
||
이 함수는 heartbeat 성격으로 주기 호출되어 그 지연을 봉주기(3분)
|
||
이내로 되돌린다. 확정되는 값 자체는 실제 체결 데이터 그대로라
|
||
백테(ws_candles 그대로 재생)와 내용 차이는 없다 — "언제 인지하냐"만
|
||
앞당긴다.
|
||
|
||
``WS_CANDLE_FORCE_CONFIRM_ENABLED=false`` 로 즉시 롤백 가능.
|
||
"""
|
||
if not get_env_bool("WS_CANDLE_FORCE_CONFIRM_ENABLED", True):
|
||
return 0
|
||
grace_sec = get_env_float("WS_CANDLE_FORCE_CONFIRM_GRACE_SEC", 0.0)
|
||
import datetime as _dt
|
||
|
||
now = _dt.datetime.now()
|
||
confirmed_n = 0
|
||
with self._lock:
|
||
for key in list(self._current.keys()):
|
||
cur = self._current.get(key)
|
||
if not cur:
|
||
continue
|
||
code, tf = key
|
||
try:
|
||
bucket_start = _dt.datetime.strptime(cur["candle_time"], "%Y%m%d%H%M")
|
||
except Exception:
|
||
continue
|
||
bucket_end = bucket_start + _dt.timedelta(minutes=tf)
|
||
if now < bucket_end + _dt.timedelta(seconds=grace_sec):
|
||
continue
|
||
confirmed_candle = self._confirm_current_bucket(key, cur)
|
||
self._current.pop(key, None)
|
||
confirmed_n += 1
|
||
logger.info(
|
||
"⏱ [봉강제확정] %s %dM %s C=%.0f — 다음 체결 없음(봉주기 경과) → 즉시 확정",
|
||
code, tf, confirmed_candle["candle_time"], confirmed_candle["close"],
|
||
)
|
||
return confirmed_n
|
||
|
||
# ------------------------------------------------------------------
|
||
# 재접속 갭 보정: REST get_minute_chart 로 빈 봉 채우기
|
||
# ------------------------------------------------------------------
|
||
|
||
def fill_gap_from_rest(self, code: str, tf: int, rest_df) -> int:
|
||
"""
|
||
WS 재접속 후 빠진 봉 구간을 REST 분봉 데이터로 채움.
|
||
|
||
[트랙 1] close 가격을 _closes / _confirmed 에 넣어 RSI 웜업
|
||
[트랙 2] 봉 dict를 Queue 에 넣어 기록원이 배치로 DB 저장 (source='rest')
|
||
|
||
Args:
|
||
code : 종목코드
|
||
tf : timeframe (분)
|
||
rest_df : get_minute_chart 반환 DataFrame (오래된→최신 순 정렬 필요)
|
||
컬럼: time, open, high, low, close, volume
|
||
|
||
Returns:
|
||
채워진 봉 수
|
||
"""
|
||
if rest_df is None or rest_df.empty:
|
||
return 0
|
||
|
||
rows: list = []
|
||
for _, row in rest_df.iterrows():
|
||
ctime = str(row.get("time", ""))[:12]
|
||
if not ctime or len(ctime) < 12:
|
||
continue
|
||
close = float(row.get("close", 0) or 0)
|
||
if close <= 0:
|
||
continue
|
||
rows.append({
|
||
"candle_time": ctime,
|
||
"open": float(row.get("open", close) or close),
|
||
"high": float(row.get("high", close) or close),
|
||
"low": float(row.get("low", close) or close),
|
||
"close": close,
|
||
"volume": int(float(row.get("volume", 0) or 0)),
|
||
"source": "rest",
|
||
})
|
||
return self.merge_confirmed_bars(code, tf, rows, log_tag="REST")
|
||
|
||
def merge_confirmed_bars(
|
||
self,
|
||
code: str,
|
||
tf: int,
|
||
bars: list,
|
||
*,
|
||
log_tag: str = "merge",
|
||
) -> int:
|
||
"""
|
||
확정봉 리스트를 RAM(+DB 큐)에 병합.
|
||
|
||
- 신규 candle_time → insert
|
||
- 기존 candle_time → volume 이 더 클 때만 OHLCV upsert
|
||
(갭보정 불완전봉을 REST/완전 롤업이 덮어쓰도록)
|
||
"""
|
||
if not bars:
|
||
return 0
|
||
with self._lock:
|
||
key = (code, tf)
|
||
closes = self._closes.setdefault(key, [])
|
||
conf_buf = self._confirmed.setdefault(key, [])
|
||
by_time = {
|
||
str(c.get("candle_time", ""))[:12]: i
|
||
for i, c in enumerate(conf_buf)
|
||
if str(c.get("candle_time", ""))[:12]
|
||
}
|
||
inserted = 0
|
||
updated = 0
|
||
for row in bars:
|
||
ctime = str(row.get("candle_time") or row.get("time") or "")[:12]
|
||
if not ctime or len(ctime) < 12:
|
||
continue
|
||
close = float(row.get("close", 0) or 0)
|
||
if close <= 0:
|
||
continue
|
||
new_vol = int(float(row.get("volume", 0) or 0))
|
||
src = str(row.get("source") or "rest")
|
||
idx = by_time.get(ctime)
|
||
|
||
if idx is not None:
|
||
old = conf_buf[idx]
|
||
old_vol = int(old.get("volume") or 0)
|
||
if new_vol <= old_vol:
|
||
continue
|
||
low_cands = [
|
||
x for x in (
|
||
float(old.get("low") or 0),
|
||
float(row.get("low", close) or close),
|
||
) if x > 0
|
||
]
|
||
candle = {
|
||
"code": code,
|
||
"tf": tf,
|
||
"candle_time": ctime,
|
||
"open": float(old.get("open") or row.get("open", close) or close),
|
||
"high": max(
|
||
float(old.get("high") or 0),
|
||
float(row.get("high", close) or close),
|
||
),
|
||
"low": min(low_cands) if low_cands else float(
|
||
row.get("low", close) or close
|
||
),
|
||
"close": close,
|
||
"volume": new_vol,
|
||
"is_confirmed": 1,
|
||
"source": src,
|
||
}
|
||
if old.get("holding_peak") is not None:
|
||
candle["holding_peak"] = old.get("holding_peak")
|
||
conf_buf[idx] = candle
|
||
closes[:] = [float(c.get("close") or 0) for c in conf_buf]
|
||
rsi2, rsi3, rsi5 = self._compute_rsi_set(closes[: idx + 1])
|
||
candle["rsi_2"] = rsi2
|
||
candle["rsi_3"] = rsi3
|
||
candle["rsi_5"] = rsi5
|
||
conf_buf[idx] = candle
|
||
try:
|
||
self._write_queue.put_nowait(candle)
|
||
except queue.Full:
|
||
pass
|
||
updated += 1
|
||
continue
|
||
|
||
closes.append(close)
|
||
if len(closes) > self._ram_buffer_max:
|
||
closes.pop(0)
|
||
rsi2, rsi3, rsi5 = self._compute_rsi_set(closes)
|
||
candle = {
|
||
"code": code,
|
||
"tf": tf,
|
||
"candle_time": ctime,
|
||
"open": float(row.get("open", close) or close),
|
||
"high": float(row.get("high", close) or close),
|
||
"low": float(row.get("low", close) or close),
|
||
"close": close,
|
||
"volume": new_vol,
|
||
"rsi_2": rsi2,
|
||
"rsi_3": rsi3,
|
||
"rsi_5": rsi5,
|
||
"is_confirmed": 1,
|
||
"source": src,
|
||
}
|
||
conf_buf.append(candle)
|
||
by_time[ctime] = len(conf_buf) - 1
|
||
if len(conf_buf) > self._ram_buffer_max:
|
||
conf_buf.sort(key=lambda x: str(x.get("candle_time", "")))
|
||
while len(conf_buf) > self._ram_buffer_max:
|
||
conf_buf.pop(0)
|
||
closes[:] = [float(c["close"]) for c in conf_buf]
|
||
by_time = {
|
||
str(c.get("candle_time", ""))[:12]: i
|
||
for i, c in enumerate(conf_buf)
|
||
if str(c.get("candle_time", ""))[:12]
|
||
}
|
||
try:
|
||
self._write_queue.put_nowait(candle)
|
||
except queue.Full:
|
||
pass
|
||
inserted += 1
|
||
if inserted or updated:
|
||
conf_buf.sort(key=lambda x: str(x.get("candle_time", "")))
|
||
closes[:] = [float(c["close"]) for c in conf_buf]
|
||
|
||
if inserted or updated:
|
||
logger.info(
|
||
"🔧 [갭보정] %s %dM → %s insert=%d update=%d RAM+DB큐",
|
||
code, tf, log_tag, inserted, updated,
|
||
)
|
||
return inserted + updated
|
||
|
||
def rollup_tf_from_1m(self, code: str, target_tf: int = 3) -> int:
|
||
"""
|
||
RAM 1분 확정봉 → target_tf 분봉 재합성 후 구멍만 보강.
|
||
꼬리(3M) 트리거 웜업: 1M REST 1회만으로 3M 준비 (WS_GAP_ROLLUP_3M_FROM_1M).
|
||
"""
|
||
from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf
|
||
|
||
tf = int(target_tf)
|
||
if tf <= 1:
|
||
return 0
|
||
with self._lock:
|
||
bars_1m = list(self._confirmed.get((code, 1), []))
|
||
if not bars_1m:
|
||
return 0
|
||
rolled = rollup_1m_bars_to_tf(bars_1m, tf)
|
||
return self.merge_confirmed_bars(
|
||
code, tf, rolled, log_tag=f"rollup_1m→{tf}M",
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# [트랙 1] RAM 버퍼 조회 — 매수/매도 루프에서 직접 호출 (DB 조회 없음)
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_latest_confirmed(self, code: str, tf: int) -> Optional[dict]:
|
||
"""
|
||
가장 최근 확정된 봉(완성된 마지막 봉)을 반환.
|
||
None이면 아직 봉이 확정되지 않음 (장 초반 등).
|
||
"""
|
||
with self._lock:
|
||
buf = self._confirmed.get((code, tf))
|
||
return buf[-1] if buf else None
|
||
|
||
def get_prev_confirmed(self, code: str, tf: int) -> Optional[dict]:
|
||
"""직전 확정봉 (최신에서 2번째). 패턴 확인용 (현재봉 - 1)."""
|
||
with self._lock:
|
||
buf = self._confirmed.get((code, tf))
|
||
return buf[-2] if buf and len(buf) >= 2 else None
|
||
|
||
def get_candles(self, code: str, tf: int, n: int = 10) -> list:
|
||
"""최근 n개 확정 봉 리스트 반환 (오래된→최신 순)."""
|
||
with self._lock:
|
||
buf = self._confirmed.get((code, tf), [])
|
||
return list(buf[-n:])
|
||
|
||
def get_confirmed_count(self, code: str, tf: int) -> int:
|
||
"""확정된 봉 수 (RSI 안정화 여부 확인용)."""
|
||
with self._lock:
|
||
return len(self._confirmed.get((code, tf), []))
|
||
|
||
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
|
||
"""
|
||
현재 진행 중인 봉(미확정, is_confirmed=0) 반환.
|
||
RSI는 포함되지 않음 (확정 봉 기준으로만 계산).
|
||
"""
|
||
with self._lock:
|
||
return dict(self._current.get((code, tf), {})) or None
|
||
|
||
def get_rsi(self, code: str, tf: int, period: int = 3) -> Optional[float]:
|
||
"""
|
||
최신 확정 봉의 RSI(period) 값 반환.
|
||
period: 2, 3, 5 중 하나 (스캘핑 단타용 초단기 RSI)
|
||
"""
|
||
candle = self.get_latest_confirmed(code, tf)
|
||
if candle is None:
|
||
return None
|
||
return candle.get(f"rsi_{period}")
|
||
|
||
def remove_code(self, code: str) -> None:
|
||
"""
|
||
유니버스에서 빠진 종목의 RAM 버퍼를 정리합니다.
|
||
_sync_subscriptions()에서 구독 해제 시 같이 호출.
|
||
등록된 모든 timeframe 의 _confirmed / _closes / _current 를 삭제.
|
||
"""
|
||
with self._lock:
|
||
for tf in list(self.timeframes):
|
||
key = (code, tf)
|
||
self._confirmed.pop(key, None)
|
||
self._closes.pop(key, None)
|
||
self._current.pop(key, None)
|
||
logger.debug("🗑️ CandleAggregator RAM 정리: %s", code)
|
||
|
||
|
||
# ======================================================================
|
||
# 키움 REST API 공통 유틸 — 봇 시작 시 WS 갭보정용
|
||
# KIS get_minute_chart() 는 당일봉만 제공하지만,
|
||
# 키움 ka10080 은 1회 호출에 최대 900봉(≈6개월) + 페이지네이션 지원.
|
||
# ======================================================================
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# 키움 토큰 매니저 싱글톤 풀
|
||
#
|
||
# kiwoom_rest_api/auth/token.py 의 TokenManager 와 동일한 설계:
|
||
# - _is_access_token_valid(): expires_dt 기반 정확한 만료 판별 (30s 버퍼)
|
||
# - _request_new_token(): 만료 시에만 발급 (au10001 rate limit 방지)
|
||
#
|
||
# 차이점: DB에서 읽은 app_key/secret 직접 주입 (환경변수 불필요)
|
||
# 싱글톤 풀: 캐시 키(domain:appkey앞8자) → 인스턴스 재사용
|
||
# 종목×timeframe마다 새 인스턴스를 만들지 않음
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
import threading as _threading
|
||
from datetime import datetime as _datetime, timedelta as _timedelta
|
||
|
||
_kiwoom_token_lock = _threading.Lock()
|
||
_kiwoom_managers: Dict[str, "KiwoomTokenManager"] = {} # 싱글톤 풀
|
||
|
||
|
||
class KiwoomTokenManager:
|
||
"""
|
||
kiwoom_rest_api/auth/token.py TokenManager 와 동일한 로직 — DB 키 직접 주입 버전.
|
||
|
||
- get_token(): 유효한 토큰이면 바로 반환, 만료 시에만 재발급 (au10001 방지)
|
||
- expires_dt 를 정확히 파싱 (하드코딩 23h 아님)
|
||
- 30초 버퍼: 경계 케이스 방지 (TokenManager 와 동일)
|
||
- 스레드 안전: 인스턴스당 Lock 보유
|
||
"""
|
||
|
||
def __init__(self, app_key: str, app_secret: str, is_mock: bool = False):
|
||
self._app_key = app_key
|
||
self._app_secret = app_secret
|
||
self._is_mock = is_mock
|
||
self._domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
|
||
self._mode_str = "모의" if is_mock else "실전"
|
||
self._token: Optional[str] = None
|
||
self._expiry: Optional[_datetime] = None
|
||
self._lock = _threading.Lock()
|
||
|
||
def _is_valid(self) -> bool:
|
||
"""만료 30초 전까지 유효 (TokenManager._is_access_token_valid 와 동일)"""
|
||
if not self._token or not self._expiry:
|
||
return False
|
||
return _datetime.now() < self._expiry - _timedelta(seconds=30)
|
||
|
||
def _request_new_token(self) -> None:
|
||
"""토큰 신규 발급 — 만료 시에만 호출됨"""
|
||
resp = requests.post(
|
||
f"https://{self._domain}/oauth2/token",
|
||
json={
|
||
"grant_type": "client_credentials",
|
||
"appkey": self._app_key,
|
||
"secretkey": self._app_secret,
|
||
},
|
||
timeout=10,
|
||
)
|
||
data = resp.json()
|
||
token = (data.get("token") or data.get("access_token") or "").strip()
|
||
if not token:
|
||
raise RuntimeError(f"키움 토큰 발급 실패 [{self._mode_str}]: {data}")
|
||
|
||
# expires_dt 정확히 파싱 (TokenManager._update_token_info 와 동일 로직)
|
||
exp_s = data.get("expires_dt", "")
|
||
try:
|
||
self._expiry = _datetime.strptime(str(exp_s), "%Y%m%d%H%M%S")
|
||
except Exception:
|
||
# expires_in 도 없으면 24h 기본 (보수적 fallback)
|
||
exp_in = data.get("expires_in", 86400)
|
||
self._expiry = _datetime.now() + _timedelta(seconds=int(exp_in))
|
||
|
||
self._token = token
|
||
logger.info(
|
||
"✅ 키움 토큰 발급 완료 [%s] (앞8자: %s…, 만료: %s)",
|
||
self._mode_str, token[:8], self._expiry.strftime("%Y-%m-%d %H:%M:%S"),
|
||
)
|
||
|
||
def get_token(self) -> Optional[str]:
|
||
"""
|
||
유효한 토큰 반환. 만료 시에만 재발급.
|
||
kiwoom_rest_api TokenManager.get_token() 호환 인터페이스.
|
||
"""
|
||
with self._lock:
|
||
if not self._is_valid():
|
||
try:
|
||
self._request_new_token()
|
||
except Exception as e:
|
||
logger.warning("⚠️ 키움 토큰 발급 예외 [%s]: %s", self._mode_str, e)
|
||
return None
|
||
return self._token
|
||
|
||
|
||
def _get_kiwoom_token_cached(
|
||
kiwoom_key: str,
|
||
kiwoom_secret: str,
|
||
is_mock: bool,
|
||
) -> Optional[str]:
|
||
"""
|
||
KiwoomTokenManager 싱글톤 풀에서 인스턴스를 꺼내 토큰 반환.
|
||
동일 키 조합은 같은 인스턴스를 재사용 → au10001 rate limit 방지.
|
||
"""
|
||
domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
|
||
cache_key = f"{domain}:{kiwoom_key[:8]}"
|
||
|
||
with _kiwoom_token_lock:
|
||
if cache_key not in _kiwoom_managers:
|
||
_kiwoom_managers[cache_key] = KiwoomTokenManager(
|
||
kiwoom_key, kiwoom_secret, is_mock=is_mock
|
||
)
|
||
mgr = _kiwoom_managers[cache_key]
|
||
|
||
return mgr.get_token()
|
||
|
||
|
||
def _get_kiwoom_creds(db) -> tuple:
|
||
"""
|
||
DB env_config 최신 행에서 키움 앱키/시크릿 반환.
|
||
KIS_MOCK 설정에 따라 MOCK / REAL 키를 자동 선택.
|
||
|
||
Returns:
|
||
(app_key, app_secret, is_mock) — 키 없으면 (None, None, False)
|
||
"""
|
||
try:
|
||
row = db.conn.execute(
|
||
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
if not row:
|
||
return None, None, False
|
||
r = dict(row)
|
||
is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes")
|
||
if is_mock:
|
||
key = str(r.get("KIWOOM_APP_KEY_MOCK", "") or "").strip()
|
||
secret = str(r.get("KIWOOM_APP_SECRET_MOCK", "") or "").strip()
|
||
else:
|
||
key = str(r.get("KIWOOM_APP_KEY_REAL", "") or "").strip()
|
||
secret = str(r.get("KIWOOM_APP_SECRET_REAL", "") or "").strip()
|
||
# 레거시 필드 폴백 (KIWOOM_APP_KEY)
|
||
if not key or not secret:
|
||
key = str(r.get("KIWOOM_APP_KEY", "") or "").strip()
|
||
secret = str(r.get("KIWOOM_APP_SECRET", "") or "").strip()
|
||
if not key or not secret:
|
||
return None, None, is_mock
|
||
return key, secret, is_mock
|
||
except Exception as e:
|
||
logger.debug("키움 크레덴셜 조회 실패: %s", e)
|
||
return None, None, False
|
||
|
||
|
||
def get_kiwoom_candles_df(
|
||
code: str,
|
||
tf_min: int,
|
||
kiwoom_key: str,
|
||
kiwoom_secret: str,
|
||
is_mock: bool = False,
|
||
n: int = 120,
|
||
) -> "object": # pd.DataFrame
|
||
"""
|
||
키움 REST API (ka10080 — 주식분봉차트조회) 로 분봉 조회.
|
||
fill_gap_from_rest() 호환 DataFrame 반환.
|
||
|
||
Args:
|
||
code : 종목코드 (6자리)
|
||
tf_min : 봉 단위 분 (1 / 3 / 5 / 10 / 15 / 30 / 45 / 60)
|
||
kiwoom_key : 키움 앱키
|
||
kiwoom_secret: 키움 앱시크릿
|
||
is_mock : True = 모의투자 도메인 사용
|
||
n : 최대 수집 봉 수 (기본 120, 60분봉 기준 약 17 영업일)
|
||
|
||
Returns:
|
||
pd.DataFrame — 컬럼: time(YYYYMMDDHHMM), open, high, low, close, volume
|
||
오래된→최신 순 (fill_gap_from_rest 기대 순서)
|
||
빈 DataFrame (오류 시)
|
||
"""
|
||
try:
|
||
import pandas as pd
|
||
except ImportError:
|
||
logger.error("pandas 미설치 → 키움 갭보정 불가")
|
||
return None # type: ignore[return-value]
|
||
|
||
domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
|
||
|
||
# ── 1. 키움 OAuth 토큰 (캐시 우선 — 23시간 재사용, au10001 rate limit 방지) ──
|
||
token = _get_kiwoom_token_cached(kiwoom_key, kiwoom_secret, is_mock)
|
||
if not token:
|
||
return pd.DataFrame()
|
||
|
||
# ── 2. ka10080 분봉 차트 조회 (페이지네이션) ──────────────────────
|
||
# 키움은 최신→과거 순으로 반환, 한 페이지에 최대 900봉
|
||
base_url = f"https://{domain}/api/dostk/chart"
|
||
headers = {
|
||
"content-type": "application/json;charset=UTF-8",
|
||
"appkey": kiwoom_key,
|
||
"appsecret": kiwoom_secret,
|
||
"authorization": f"Bearer {token}",
|
||
"api-id": "ka10080",
|
||
"cont-yn": "N",
|
||
"next-key": "",
|
||
}
|
||
body = {
|
||
"stk_cd": code,
|
||
"tic_scope": str(tf_min), # "1" / "3" / "15" / "60" etc.
|
||
"upd_stkpc_tp": "1", # 수정주가 반영
|
||
}
|
||
|
||
rows: list = []
|
||
while len(rows) < n:
|
||
try:
|
||
resp = requests.post(base_url, json=body, headers=headers, timeout=15)
|
||
data = resp.json()
|
||
except Exception as e:
|
||
logger.warning("⚠️ 키움 ka10080 조회 실패 (%s %dM): %s", code, tf_min, e)
|
||
break
|
||
|
||
records = data.get("stk_min_pole_chart_qry") or []
|
||
if not records:
|
||
break
|
||
|
||
for rec in records:
|
||
raw_dt = str(rec.get("cntr_tm", ""))
|
||
if len(raw_dt) < 12:
|
||
continue
|
||
try:
|
||
rows.append({
|
||
# cntr_tm = YYYYMMDDHHMMSS → 12자리로 잘라 YYYYMMDDHHMM
|
||
"time": raw_dt[:12],
|
||
"open": abs(float(rec.get("open_pric", 0) or 0)),
|
||
"high": abs(float(rec.get("high_pric", 0) or 0)),
|
||
"low": abs(float(rec.get("low_pric", 0) or 0)),
|
||
"close": abs(float(rec.get("cur_prc", 0) or 0)),
|
||
"volume": abs(int(float(rec.get("trde_qty", 0) or 0))),
|
||
})
|
||
except (ValueError, TypeError):
|
||
continue
|
||
if len(rows) >= n:
|
||
break
|
||
|
||
# 연속 조회 여부 확인 (응답 헤더)
|
||
cont_yn = str(resp.headers.get("cont-yn", "N")).upper()
|
||
next_key = str(resp.headers.get("next-key", "")).strip()
|
||
if cont_yn != "Y" or not next_key or len(rows) >= n:
|
||
break
|
||
headers["cont-yn"] = cont_yn
|
||
headers["next-key"] = next_key
|
||
time.sleep(0.35) # 키움 API 레이트리밋
|
||
|
||
if not rows:
|
||
logger.debug("키움 ka10080 반환 행 없음 (%s %dM)", code, tf_min)
|
||
return pd.DataFrame()
|
||
|
||
# 키움은 최신→과거 순 → fill_gap_from_rest 는 오래된→최신 순 필요 → 역순
|
||
df = pd.DataFrame(rows[:n][::-1])
|
||
logger.info("✅ 키움 %dM 갭보정 데이터: %s %d봉", tf_min, code, len(df))
|
||
return df
|
||
|
||
|
||
def fetch_kiwoom_stock_meta_detail(
|
||
code: str,
|
||
kiwoom_key: str,
|
||
kiwoom_secret: str,
|
||
is_mock: bool = False,
|
||
*,
|
||
max_retries: Optional[int] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
키움 ka10001 상세 결과 — 성공/실패 사유를 로그·스크립트용으로 반환.
|
||
|
||
Returns:
|
||
{
|
||
"ok": bool,
|
||
"meta": {"flo_stk": int, "dstr_stk": int} | None,
|
||
"http_status": int,
|
||
"return_code": int | None,
|
||
"return_msg": str,
|
||
"reason": str,
|
||
}
|
||
"""
|
||
code = str(code or "").strip()
|
||
empty: Dict[str, Any] = {
|
||
"ok": False,
|
||
"meta": None,
|
||
"http_status": 0,
|
||
"return_code": None,
|
||
"return_msg": "",
|
||
"reason": "",
|
||
}
|
||
if not code or not kiwoom_key or not kiwoom_secret:
|
||
empty["reason"] = "입력값없음(code/키)"
|
||
return empty
|
||
token = _get_kiwoom_token_cached(kiwoom_key, kiwoom_secret, is_mock)
|
||
if not token:
|
||
empty["reason"] = "키움토큰발급실패"
|
||
return empty
|
||
|
||
retries = max_retries
|
||
if retries is None:
|
||
retries = get_env_int("KIWOOM_KA10001_RETRY_MAX", 4)
|
||
retry_base = get_env_float("KIWOOM_KA10001_RETRY_SLEEP_SEC", 2.0)
|
||
|
||
domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
|
||
url = f"https://{domain}/api/dostk/stkinfo"
|
||
headers = {
|
||
"content-type": "application/json;charset=UTF-8",
|
||
"appkey": kiwoom_key,
|
||
"appsecret": kiwoom_secret,
|
||
"authorization": f"Bearer {token}",
|
||
"api-id": "ka10001",
|
||
"cont-yn": "N",
|
||
"next-key": "",
|
||
}
|
||
|
||
last: Dict[str, Any] = dict(empty)
|
||
for attempt in range(max(1, int(retries))):
|
||
try:
|
||
resp = requests.post(
|
||
url, json={"stk_cd": code}, headers=headers, timeout=12,
|
||
)
|
||
http_st = int(resp.status_code)
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
data = {}
|
||
rc_raw = data.get("return_code", 1)
|
||
try:
|
||
rc = int(rc_raw)
|
||
except Exception:
|
||
rc = 1
|
||
msg = str(data.get("return_msg") or "").strip()
|
||
|
||
if http_st == 429 or rc == 5:
|
||
last = {
|
||
"ok": False,
|
||
"meta": None,
|
||
"http_status": http_st,
|
||
"return_code": rc,
|
||
"return_msg": msg,
|
||
"reason": "rate_limit_1700",
|
||
}
|
||
if attempt + 1 < retries:
|
||
wait = retry_base * (attempt + 1) + random.uniform(0.2, 0.6)
|
||
logger.warning(
|
||
"키움 ka10001 레이트리밋 (%s) http=%s rc=%s — %.1fs 후 재시도 %d/%d",
|
||
code, http_st, rc, wait, attempt + 2, retries,
|
||
)
|
||
time.sleep(wait)
|
||
continue
|
||
return last
|
||
|
||
if rc != 0:
|
||
last = {
|
||
"ok": False,
|
||
"meta": None,
|
||
"http_status": http_st,
|
||
"return_code": rc,
|
||
"return_msg": msg,
|
||
"reason": "api_error",
|
||
}
|
||
logger.debug("키움 ka10001 API오류 (%s): rc=%s %s", code, rc, msg)
|
||
return last
|
||
|
||
flo = int(float(str(data.get("flo_stk", "0") or "0").replace(",", "") or 0))
|
||
dstr = int(float(str(data.get("dstr_stk", "0") or "0").replace(",", "") or 0))
|
||
if flo <= 0 and dstr <= 0:
|
||
last = {
|
||
"ok": False,
|
||
"meta": None,
|
||
"http_status": http_st,
|
||
"return_code": rc,
|
||
"return_msg": msg,
|
||
"reason": "주식수없음(flo/dstr=0)",
|
||
}
|
||
return last
|
||
|
||
return {
|
||
"ok": True,
|
||
"meta": {"flo_stk": flo, "dstr_stk": dstr},
|
||
"http_status": http_st,
|
||
"return_code": rc,
|
||
"return_msg": msg,
|
||
"reason": "",
|
||
}
|
||
except Exception as e:
|
||
last = {
|
||
"ok": False,
|
||
"meta": None,
|
||
"http_status": 0,
|
||
"return_code": None,
|
||
"return_msg": str(e),
|
||
"reason": "exception",
|
||
}
|
||
if attempt + 1 < retries:
|
||
time.sleep(retry_base)
|
||
continue
|
||
logger.debug("키움 ka10001 예외 (%s): %s", code, e)
|
||
return last
|
||
return last
|
||
|
||
|
||
def fetch_kiwoom_stock_meta(
|
||
code: str,
|
||
kiwoom_key: str,
|
||
kiwoom_secret: str,
|
||
is_mock: bool = False,
|
||
) -> Optional[Dict[str, int]]:
|
||
"""
|
||
키움 ka10001 주식기본정보 — 상장/유통주식수 (WSManager·stock_share_meta DB).
|
||
|
||
Returns:
|
||
{"flo_stk": int, "dstr_stk": int} 또는 None
|
||
"""
|
||
res = fetch_kiwoom_stock_meta_detail(
|
||
code, kiwoom_key, kiwoom_secret, is_mock=is_mock,
|
||
)
|
||
if not res.get("ok"):
|
||
return None
|
||
return res.get("meta")
|
||
|