Files
kis_bot/kis_trader/network/market_guard.py
Hwang f61c471aac 브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
2026-05-05 21:04:17 +09:00

331 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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`` 로 수동 해제
설계 철학
---------
- 거래소 공식 서킷브레이커 (KOSPI -8%) 보다 훨씬 빨리 반응 → 봇 보호 우선.
- 매수만 차단, 매도는 평소처럼 진행 → 봇이 살아있어야 손절·익절 가능.
- 백테스트 X (지수 데이터 누적 안 되어 있음) → 실거래로 임계치 튜닝.
- ``MARKET_GUARD_ENABLED=false`` 가 기본값. 운영 1~2주 모니터링 후 활성화.
환경변수 (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)
"""
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")
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분 비교용
# 보수적으로 10분치 (deque maxlen=120 ≈ poll 5초 × 120 = 10분)
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 _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))
# 감시 지수: "0001", "1001", "both", 또는 콤마구분 ("0001,1001")
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()]
# 알 수 없는 코드 들어오면 안전하게 KOSPI 만 감시
self.index_codes = codes if codes else ["0001"]
# ------------------------------------------------------------------
def start(self) -> bool:
"""백그라운드 쓰레드 기동. 비활성 상태로 시작해도 thread 자체는 살아있음
→ DB 토글로 즉시 활성화 가능."""
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)",
self.enabled, self.index_codes, self.drop_5min, self.drop_daily,
self.recovery_pct, self.poll_sec,
)
return True
def stop(self) -> None:
self._running = False
# ------------------------------------------------------------------
# 외부 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
while self._running:
try:
self._reload_config()
# 비활성 시 천천히 대기 (DB 폴링 부하 ↓, 토글 즉시 반응)
if not self.enabled:
time.sleep(max(10, self.poll_sec))
continue
# 장중에만 감시 (장외에는 지수 데이터 정적 → 폴링 무의미)
if not self._is_market_hours():
time.sleep(60)
continue
self._tick()
# 60초에 1번 상태 로그 (정상 동작 확인용)
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()
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:
# 전일 종가 저장 (일중 누적 판정용 — 매번 갱신 OK, 같은 값)
self._prev_close[code] = prdy_close
# 가격 deque 업데이트 (10분 이상 된 데이터 자동 삭제)
hist = self._history.setdefault(code, deque(maxlen=120))
hist.append((now, cur))
cutoff = now - 600
while hist and hist[0][0] < cutoff:
hist.popleft()
# ── PANIC 판정 ────────────────────────────────
self._evaluate_panic(code, cur, prdy_close, now)
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
# 5분 누적 등락률 (5분 전 가격 vs 현재가)
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 # 5분치 데이터 아직 부족 (시작 직후)
if not self._panic:
# 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,
)
else:
# PANIC 해제 조건: 트리거된 지수의 5분 누적 +N% 반등
# (다른 지수 반등은 무시 — 같은 지수가 회복해야 진짜 회복)
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,
)
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 이전(≤) 시각 중 가장 최근 가격 반환.
없으면 None (= 데이터 부족, 5분 비교 스킵)."""
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
# 평상시 한 줄 요약 (감시 지수별 일중/5분 등락률)
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))