브랜치 분리 방식: A / B / C

A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
2026-05-05 21:04:17 +09:00
parent c2b2b711e0
commit f61c471aac
58 changed files with 803502 additions and 1430 deletions

View File

@@ -0,0 +1,330 @@
"""
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))