""" kis_trader/network/market_guard.py — 시장 급락 서킷브레이커 ============================================================== KOSPI/KOSDAQ 종합지수를 주기적으로 폴링해, 다음 조건 충족 시 PANIC 모드 진입: 1) 5분 누적 -N% 하락 (기본 -2%) 2) 일중 누적 -N% 하락 (전일 종가 대비, 기본 -3%) PANIC 모드 시: - ``BaseStrategy._scan_and_buy()`` 가 신규 매수를 즉시 차단 - 기존 보유 종목 매도는 정상 동작 (포지션 정리·손실 확대 방지) 해제: - 5분 누적 +N% 반등 시 자동 해제 (기본 +1%) - 또는 운영자가 DB ``MARKET_GUARD_ENABLED=false`` 로 수동 해제 영속 (재시작 구멍 방지) ---------------------- - 지수 1분봉: ``ws_candles`` code ``MG0001`` / ``MG1001`` … (timeframe=1, source=mg) - PANIC 상태: ``kv_store`` ``market_guard.*`` (진입/해제·기동 복원 시 즉시 재판정) - ``bootstrap_sync()``: 전략 쓰레드 기동 **전** 1회 — DB 봉 복원 → API 1틱 → PANIC 재계산 설계 철학 --------- - 거래소 공식 서킷브레이커 (KOSPI -8%) 보다 훨씬 빨리 반응 → 봇 보호 우선. - 매수만 차단, 매도는 평소처럼 진행 → 봇이 살아있어야 손절·익절 가능. - ``MARKET_GUARD_ENABLED=false`` 가 기본값. true 일 때만 지수 API·봉 저장·PANIC 판정. 환경변수 (DB env_config, 매 tick 재조회 → 운영 중 즉시 반영) ------------------------------------------------------------ MARKET_GUARD_ENABLED (true/false, 기본 false) MARKET_GUARD_5MIN_DROP_PCT (실수, 기본 2.0) MARKET_GUARD_DAILY_DROP_PCT (실수, 기본 3.0) MARKET_GUARD_RECOVERY_PCT (실수, 기본 1.0) MARKET_GUARD_INDEX_CODE ("0001"|"1001"|"both", 기본 "both") MARKET_GUARD_POLL_SEC (정수, 기본 30) MARKET_GUARD_INDEX_CANDLE_KEEP_MIN (정수, 기본 20) — 재시작 시 복원할 1분봉 개수 MARKET_GUARD_PERSIST_STATE (true/false, 기본 true) — kv PANIC 영속 """ from __future__ import annotations import threading import time from collections import deque from datetime import datetime as dt from typing import Deque, Dict, List, Optional, Tuple from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger logger = get_logger("kis_trader.market_guard") # ws_candles 가상 종목코드: MG + 4자리 지수코드 (예: MG0001=KOSPI) _WS_CODE_PREFIX = "MG" _TIMEFRAME_1M = 1 _SOURCE_INDEX = "mg" KV_PANIC = "market_guard.panic" KV_PANIC_REASON = "market_guard.panic_reason" KV_PANIC_SINCE = "market_guard.panic_since_epoch" KV_PANIC_INDEX = "market_guard.panic_index" KV_PREV_CLOSE_PREFIX = "market_guard.prev_close." class MarketGuard: """KOSPI/KOSDAQ 지수 폴링 + PANIC 판정 매니저. 독립 백그라운드 쓰레드.""" # 지수 코드 → 사람용 이름 _INDEX_NAMES = { "0001": "KOSPI", "1001": "KOSDAQ", "2001": "KOSPI200", } def __init__(self, *, client, db=None): self.client = client self.db = db self._thread: Optional[threading.Thread] = None self._running = False self._lock = threading.Lock() # 지수별 최근 가격 deque [(epoch_sec, value), ...] — 5분 비교용 self._history: Dict[str, Deque[Tuple[float, float]]] = {} # 지수별 전일 종가 (일중 누적 판정용) self._prev_close: Dict[str, float] = {} # PANIC 상태 (전역 — 어떤 지수가 트리거했든 매수 전체 차단) self._panic = False self._panic_reason: str = "" self._panic_since: float = 0.0 self._panic_index: str = "" self._reload_config() # ------------------------------------------------------------------ def _db_raw(self): if self.db is None: return None return getattr(self.db, "raw", self.db) @staticmethod def _ws_code(index_code: str) -> str: """ws_candles 저장용 6자리 코드 (MG0001 …).""" c = (index_code or "").strip() return f"{_WS_CODE_PREFIX}{c}"[:20] @staticmethod def _candle_minute_key(ts: Optional[float] = None) -> str: t = dt.fromtimestamp(ts if ts is not None else time.time()) return t.strftime("%Y%m%d%H%M") @staticmethod def _minute_end_epoch(candle_time: str) -> Optional[float]: """YYYYMMDDHHMM 봉의 종료 시각(epoch, 분 말).""" ct = (candle_time or "").strip() if len(ct) < 12: return None try: base = dt.strptime(ct[:12], "%Y%m%d%H%M") return base.timestamp() + 60.0 except ValueError: return None # ------------------------------------------------------------------ def _reload_config(self) -> None: """매 tick DB 재조회 (운영 중 임계치 튜닝 즉시 반영).""" self.enabled = get_env_bool("MARKET_GUARD_ENABLED", False) self.drop_5min = get_env_float("MARKET_GUARD_5MIN_DROP_PCT", 2.0) self.drop_daily = get_env_float("MARKET_GUARD_DAILY_DROP_PCT", 3.0) self.recovery_pct = get_env_float("MARKET_GUARD_RECOVERY_PCT", 1.0) self.poll_sec = max(5, get_env_int("MARKET_GUARD_POLL_SEC", 30)) self.candle_keep_min = max(5, get_env_int("MARKET_GUARD_INDEX_CANDLE_KEEP_MIN", 20)) self.persist_state = get_env_bool("MARKET_GUARD_PERSIST_STATE", True) idx_raw = (get_env_from_db("MARKET_GUARD_INDEX_CODE", "both") or "both") \ .strip().lower() if idx_raw == "both": self.index_codes = ["0001", "1001"] else: codes = [c.strip() for c in idx_raw.split(",") if c.strip()] self.index_codes = codes if codes else ["0001"] # ------------------------------------------------------------------ def start(self) -> bool: """백그라운드 쓰레드 기동. ``MARKET_GUARD_ENABLED=false`` 면 루프는 대기만(지수 API·봉 저장 없음). DB 에서 true 로 바꾸면 재시작 없이 다음 주기부터 감시 시작. """ if self._thread and self._thread.is_alive(): return True self._running = True self._thread = threading.Thread( target=self._loop, daemon=True, name="MarketGuard" ) self._thread.start() logger.info( "✅ MarketGuard 쓰레드 시작 (enabled=%s, indexes=%s, " "5min=-%.1f%%, daily=-%.1f%%, recovery=+%.1f%%, poll=%ds, " "candle_keep=%d분, persist=%s)", self.enabled, self.index_codes, self.drop_5min, self.drop_daily, self.recovery_pct, self.poll_sec, self.candle_keep_min, self.persist_state, ) return True def stop(self) -> None: self._running = False def bootstrap_sync(self) -> None: """기동 시 1회: DB 1분봉·kv PANIC 복원 후 즉시 API 폴링·재판정. 전략 매수 루프보다 **먼저** 호출해야 재시작 직후 매수 구멍을 막을 수 있음. ``MARKET_GUARD_ENABLED=false`` 면 no-op. """ self._reload_config() if not self.enabled: logger.info("ℹ️ [MarketGuard] bootstrap 스킵 (ENABLED=false)") return self._restore_history_from_db() self._restore_panic_from_kv() try: self._tick() except Exception as e: logger.error("[MarketGuard] bootstrap _tick 실패: %s", e) with self._lock: panic = self._panic reason = self._panic_reason if panic: logger.warning( "⛔ [MarketGuard] bootstrap 완료 — PANIC 유지/재진입: %s", reason, ) else: logger.info("📊 [MarketGuard] bootstrap 완료 — 정상 (신규 매수 허용)") # ------------------------------------------------------------------ # DB 영속 # ------------------------------------------------------------------ def _restore_history_from_db(self) -> None: """ws_candles MG* 1분봉 → _history deque 복원 (5분 판정용).""" raw = self._db_raw() if raw is None: return limit = max(self.candle_keep_min, 12) with self._lock: for code in self.index_codes: ws_code = self._ws_code(code) try: rows = raw.get_ws_candles(ws_code, _TIMEFRAME_1M, limit=limit) except Exception as e: logger.debug("지수 봉 복원 실패 (%s): %s", ws_code, e) rows = [] hist: Deque[Tuple[float, float]] = deque(maxlen=120) for row in rows or []: ep = self._minute_end_epoch(str(row.get("candle_time", ""))) try: cl = float(row.get("close", 0) or 0) except (TypeError, ValueError): continue if ep and cl > 0: hist.append((ep, cl)) if hist: self._history[code] = hist pk = f"{KV_PREV_CLOSE_PREFIX}{code}" try: pv = raw.get_kv(pk) if pv: self._prev_close[code] = float(str(pv).replace(",", "")) except Exception: pass logger.info( "📂 [MarketGuard] DB 1분봉 복원 — %s", ", ".join( f"{self._INDEX_NAMES.get(c, c)}={len(self._history.get(c, []))}봉" for c in self.index_codes ), ) def _restore_panic_from_kv(self) -> None: if not self.persist_state: return raw = self._db_raw() if raw is None: return try: flag = (raw.get_kv(KV_PANIC) or "0").strip() if flag not in ("1", "true", "True"): return reason = (raw.get_kv(KV_PANIC_REASON) or "").strip() idx = (raw.get_kv(KV_PANIC_INDEX) or "").strip() since_s = raw.get_kv(KV_PANIC_SINCE) since = float(since_s) if since_s else time.time() except Exception as e: logger.debug("PANIC kv 복원 실패: %s", e) return with self._lock: self._panic = True self._panic_reason = reason or "kv 복원" self._panic_index = idx self._panic_since = since logger.warning( "📂 [MarketGuard] kv PANIC 복원 — %s (이후 bootstrap tick 에서 재판정)", reason or idx, ) def _persist_panic(self) -> None: if not self.persist_state: return raw = self._db_raw() if raw is None: return with self._lock: panic = self._panic reason = self._panic_reason since = self._panic_since idx = self._panic_index try: raw.set_kv(KV_PANIC, "1" if panic else "0") raw.set_kv(KV_PANIC_REASON, reason or "") raw.set_kv(KV_PANIC_SINCE, str(since if panic else 0.0)) raw.set_kv(KV_PANIC_INDEX, idx or "") except Exception as e: logger.debug("PANIC kv 저장 실패: %s", e) def _persist_prev_close(self, code: str, prdy_close: float) -> None: if not self.persist_state or prdy_close <= 0: return raw = self._db_raw() if raw is None: return try: raw.set_kv(f"{KV_PREV_CLOSE_PREFIX}{code}", str(prdy_close)) except Exception: pass def _upsert_index_minute_candle(self, code: str, cur: float, now: float) -> None: """폴링 샘플을 1분 OHLC 로 ws_candles 에 적재 (재시작 5분 판정용).""" raw = self._db_raw() if raw is None or cur <= 0: return ws_code = self._ws_code(code) minute_key = self._candle_minute_key(now) try: latest = raw.get_latest_ws_candle(ws_code, _TIMEFRAME_1M) except Exception: latest = None if latest: prev_key = str(latest.get("candle_time", "")).strip() if prev_key and prev_key != minute_key: try: o = float(latest.get("open", cur)) h = float(latest.get("high", cur)) l = float(latest.get("low", cur)) c = float(latest.get("close", cur)) raw.upsert_ws_candle( ws_code, _TIMEFRAME_1M, prev_key, o, h, l, c, 0, is_confirmed=1, source=_SOURCE_INDEX, ) except Exception as e: logger.debug("지수 확정봉 저장 실패: %s", e) if latest and str(latest.get("candle_time", "")).strip() == minute_key: try: o = float(latest.get("open", cur)) h = max(float(latest.get("high", cur)), cur) l = min(float(latest.get("low", cur)), cur) raw.upsert_ws_candle( ws_code, _TIMEFRAME_1M, minute_key, o, h, l, cur, 0, is_confirmed=0, source=_SOURCE_INDEX, ) except Exception: pass else: try: raw.upsert_ws_candle( ws_code, _TIMEFRAME_1M, minute_key, cur, cur, cur, cur, 0, is_confirmed=0, source=_SOURCE_INDEX, ) except Exception: pass # ------------------------------------------------------------------ # 외부 API — 전략에서 호출 # ------------------------------------------------------------------ def is_panic(self) -> bool: """매수 차단 여부. BaseStrategy._scan_and_buy() 가 매 후보마다 호출.""" with self._lock: return self.enabled and self._panic def panic_reason(self) -> str: """현재 PANIC 사유 (로그용).""" with self._lock: return self._panic_reason def status(self) -> Dict: """heartbeat 로그 등에서 노출용 — 현재 상태 요약.""" with self._lock: now = time.time() indexes_status = [] for code in self.index_codes: name = self._INDEX_NAMES.get(code, code) hist = self._history.get(code) prdy = self._prev_close.get(code, 0) if not hist or prdy <= 0: indexes_status.append({"name": name, "ready": False}) continue cur = hist[-1][1] daily_pct = (cur - prdy) / prdy * 100.0 five = self._lookup_price_at(hist, now - 300) five_pct = ((cur - five) / five * 100.0) if five else 0.0 indexes_status.append({ "name": name, "ready": True, "daily_pct": round(daily_pct, 2), "five_min_pct": round(five_pct, 2), }) return { "enabled": self.enabled, "panic": self._panic, "reason": self._panic_reason, "since_min": round((now - self._panic_since) / 60.0, 1) if self._panic else 0.0, "indexes": indexes_status, } # ------------------------------------------------------------------ # 메인 루프 # ------------------------------------------------------------------ def _loop(self) -> None: last_log_ts = 0.0 was_enabled = False while self._running: try: self._reload_config() if not self.enabled: if was_enabled: logger.info("ℹ️ [MarketGuard] 비활성 — 지수 폴링·봉 저장 중지") was_enabled = False time.sleep(max(10, self.poll_sec)) continue if not was_enabled: logger.info("▶ [MarketGuard] 활성화 — 지수 감시·봉 저장 시작") self._restore_history_from_db() was_enabled = True if not self._is_market_hours(): time.sleep(60) continue self._tick() now = time.time() if now - last_log_ts >= 60: last_log_ts = now self._log_status() except Exception as e: logger.error("MarketGuard 루프 예외: %s", e) time.sleep(self.poll_sec) @staticmethod def _is_market_hours() -> bool: """장 시간 체크 (BaseStrategy.check_market_status 와 동일 정책).""" if get_env_bool("FORCE_MARKET_OPEN", False): return True now = dt.now() h, m = now.hour, now.minute return (9 <= h < 15) or (h == 15 and m <= 30) # ------------------------------------------------------------------ def _tick(self) -> None: """모든 감시 지수 1회 폴링 + PANIC 판정.""" now = time.time() panic_changed = False for code in self.index_codes: try: out = self.client.inquire_index_price(code) except Exception as e: logger.debug("지수 조회 실패 (%s): %s", code, e) continue if not out: continue try: cur = float(str(out.get("bstp_nmix_prpr", 0)).replace(",", "")) prdy_close = float( str(out.get("bstp_nmix_prdy_clpr", 0)).replace(",", "") ) except (ValueError, AttributeError): continue if cur <= 0 or prdy_close <= 0: continue with self._lock: self._prev_close[code] = prdy_close hist = self._history.setdefault(code, deque(maxlen=120)) hist.append((now, cur)) cutoff = now - 600 while hist and hist[0][0] < cutoff: hist.popleft() prev_panic = self._panic self._evaluate_panic(code, cur, prdy_close, now) if self._panic != prev_panic: panic_changed = True self._persist_prev_close(code, prdy_close) self._upsert_index_minute_candle(code, cur, now) if panic_changed: self._persist_panic() def _evaluate_panic( self, code: str, cur: float, prdy_close: float, now: float, ) -> None: """단일 지수 기준 PANIC 진입/해제 판정. _lock 보유 상태 가정.""" name = self._INDEX_NAMES.get(code, code) daily_chg = (cur - prdy_close) / prdy_close * 100.0 hist = self._history.get(code, deque()) five_min_ago_price = self._lookup_price_at(hist, now - 300) if five_min_ago_price: five_min_chg = (cur - five_min_ago_price) / five_min_ago_price * 100.0 else: five_min_chg = 0.0 if not self._panic: reasons: List[str] = [] if daily_chg <= -abs(self.drop_daily): reasons.append(f"일중 {daily_chg:+.2f}% (한계 -{self.drop_daily:.1f}%)") if five_min_chg <= -abs(self.drop_5min): reasons.append(f"5분 {five_min_chg:+.2f}% (한계 -{self.drop_5min:.1f}%)") if reasons: self._panic = True self._panic_index = code self._panic_reason = f"{name} " + " / ".join(reasons) self._panic_since = now logger.warning( "🚨 [MarketGuard] PANIC 진입: %s — 모든 전략 신규 매수 차단", self._panic_reason, ) if get_env_bool("OPS_ALERT_MARKET_GUARD_ENABLED", True): try: from kis_trader.utils.ops_alert import ops_alert ops_alert( "market_guard_panic", "MarketGuard PANIC 진입 — 신규매수 차단", detail=self._panic_reason, level="critical", session_only=False, ) except Exception: pass else: if code != self._panic_index: return if five_min_chg >= abs(self.recovery_pct): duration_min = (now - self._panic_since) / 60.0 logger.warning( "✅ [MarketGuard] PANIC 해제: %s 5분 %+.2f%% 반등 " "(지속 %.1f분, 매수 재개)", name, five_min_chg, duration_min, ) if get_env_bool("OPS_ALERT_MARKET_GUARD_ENABLED", True): try: from kis_trader.utils.ops_alert import ops_alert ops_alert( "market_guard_clear", "MarketGuard PANIC 해제 — 매수 재개", detail=f"{name} 5분 {five_min_chg:+.2f}% (지속 {duration_min:.1f}분)", level="warn", session_only=False, ) except Exception: pass self._panic = False self._panic_reason = "" self._panic_index = "" self._panic_since = 0.0 @staticmethod def _lookup_price_at( hist: Deque[Tuple[float, float]], target_ts: float, ) -> Optional[float]: """deque 에서 target_ts 이전(≤) 시각 중 가장 최근 가격 반환.""" best = None for ts, val in hist: if ts <= target_ts: best = val else: break return best def _log_status(self) -> None: """60초에 한 번씩 평상시/PANIC 상태 로그.""" with self._lock: if self._panic: duration_min = (time.time() - self._panic_since) / 60.0 logger.warning( "⛔ [MarketGuard] PANIC 모드 진행 중 — %s (지속 %.1f분)", self._panic_reason, duration_min, ) return parts = [] now = time.time() for code in self.index_codes: name = self._INDEX_NAMES.get(code, code) hist = self._history.get(code) prdy = self._prev_close.get(code, 0) if not hist or prdy <= 0: parts.append(f"{name}=대기") continue cur = hist[-1][1] daily = (cur - prdy) / prdy * 100.0 five = self._lookup_price_at(hist, now - 300) five_chg = ((cur - five) / five * 100.0) if five else 0.0 parts.append(f"{name} 일중{daily:+.2f}% 5분{five_chg:+.2f}%") if parts: logger.info("📊 [MarketGuard] 정상 — %s", " | ".join(parts))