"""시장 상황별 대응 플레이북 (Market Event Playbook) 예측 가능한 시장 이벤트(FOMC, 한은 금통위, 옵션 만기, 실적 시즌 등)에 자동 대응하는 시스템. 주요 기능: - get_upcoming_events(days=7): 향후 7일 이내 예정 이벤트 목록 - get_today_playbook(): 오늘 적용할 행동 지침 (position_multiplier, entry_allowed 등) - check_realtime_events(): 서킷브레이커, VI 등 실시간 감지 알림 통합 포인트: - market_analyzer.check_market_regime() 에서 playbook 결과를 오버레이 - daytrading/entry_service.scan_and_enter() 에서 FOMC/옵션만기 당일 차단 - swing/entry_service.execute_swing_entries() 에서 position_multiplier 반영 아키텍처 규칙 준수: - DB 직접 접근 없음 (순수 로직 + 메모리 캐시) - 기존 함수 시그니처 변경 없음 - settings 싱글톤 사용 """ import logging import time from dataclasses import dataclass, field from datetime import date, datetime, timedelta from enum import Enum from typing import List, Optional try: import pytz KST = pytz.timezone("Asia/Seoul") except ImportError: from datetime import timezone, timedelta as td KST = timezone(td(hours=9)) from app.core.log_throttle import warn_throttled # noqa: E402 logger = logging.getLogger(__name__) # ═══════════════════════════════════════════════════════════════════════════════ # 1. Enums & Data Classes # ═══════════════════════════════════════════════════════════════════════════════ class MarketEventType(str, Enum): """예측 가능한 시장 이벤트 유형.""" FOMC_MEETING = "fomc" BOK_MEETING = "bok" QUAD_WITCHING = "quad_witching" KR_OPTIONS_EXPIRY = "kr_options" EARNINGS_SEASON = "earnings" EX_DIVIDEND = "ex_dividend" WINDOW_DRESSING = "window_dressing" TAX_LOSS_SELLING = "tax_loss" GOLDEN_WEEK = "golden_week" US_CPI = "us_cpi" US_EMPLOYMENT = "us_employment" CIRCUIT_BREAKER = "circuit_breaker" VI_TRIGGERED = "vi_triggered" LARGE_BLOCK_TRADE = "block_trade" class EventPhase(str, Enum): """이벤트 대비 현재 시점의 단계.""" PRE = "pre" # 이벤트 전 (D-N) DAY_OF = "day_of" # 이벤트 당일 POST = "post" # 이벤트 후 (D+N) ACTIVE = "active" # 실시간 진행 중 (서킷브레이커, VI 등) NONE = "none" # 해당 없음 @dataclass class MarketEvent: """개별 시장 이벤트.""" event_type: MarketEventType event_date: date description: str phase: EventPhase = EventPhase.NONE days_until: int = 0 # 양수: 미래, 0: 당일, 음수: 과거 @dataclass class PlaybookAction: """오늘 적용할 행동 지침. 여러 이벤트가 겹치면 가장 보수적인 값 적용.""" position_multiplier: float = 1.0 # 0.0 ~ 1.0 (포지션 배율) entry_allowed: bool = True # 신규 진입 허용 여부 daytrading_allowed: bool = True # 데이트레이딩 허용 여부 reason: str = "" # 적용 사유 active_events: List[MarketEvent] = field(default_factory=list) def to_dict(self) -> dict: return { "position_multiplier": self.position_multiplier, "entry_allowed": self.entry_allowed, "daytrading_allowed": self.daytrading_allowed, "reason": self.reason, "active_events": [ { "type": e.event_type.value, "date": e.event_date.isoformat(), "description": e.description, "phase": e.phase.value, "days_until": e.days_until, } for e in self.active_events ], } @dataclass class RealtimeAlert: """실시간 감지 알림.""" event_type: MarketEventType description: str severity: str = "warning" # "warning", "critical" timestamp: Optional[datetime] = None data: dict = field(default_factory=dict) # ═══════════════════════════════════════════════════════════════════════════════ # 2. 경제 캘린더 데이터 (연간 하드코딩 + 규칙 기반 계산) # ═══════════════════════════════════════════════════════════════════════════════ # 2026년 FOMC 일정 (발표일 기준, 연 8회) # 출처: Federal Reserve 공식 일정 _FOMC_DATES_2026 = [ date(2026, 1, 28), date(2026, 3, 18), date(2026, 5, 6), date(2026, 6, 17), date(2026, 7, 29), date(2026, 9, 16), date(2026, 11, 4), date(2026, 12, 16), ] # 2026년 한국은행 금통위 일정 (연 8회) _BOK_DATES_2026 = [ date(2026, 1, 16), date(2026, 2, 27), date(2026, 4, 10), date(2026, 5, 29), date(2026, 7, 10), date(2026, 8, 28), date(2026, 10, 16), date(2026, 11, 27), ] # 2025년 일정 (참조용) _FOMC_DATES_2025 = [ date(2025, 1, 29), date(2025, 3, 19), date(2025, 5, 7), date(2025, 6, 18), date(2025, 7, 30), date(2025, 9, 17), date(2025, 11, 5), date(2025, 12, 17), ] _BOK_DATES_2025 = [ date(2025, 1, 16), date(2025, 2, 27), date(2025, 4, 11), date(2025, 5, 29), date(2025, 7, 11), date(2025, 8, 28), date(2025, 10, 16), date(2025, 11, 28), ] # 미국 CPI 발표 예정일 (2026) - 보통 매월 중순 _US_CPI_DATES_2026 = [ date(2026, 1, 14), date(2026, 2, 11), date(2026, 3, 11), date(2026, 4, 14), date(2026, 5, 13), date(2026, 6, 10), date(2026, 7, 15), date(2026, 8, 12), date(2026, 9, 16), date(2026, 10, 14), date(2026, 11, 12), date(2026, 12, 10), ] # 한국 주요 연휴 기간 (설날, 추석) _GOLDEN_WEEK_2026 = [ # 설날 2026-02-16 ~ 02-18 (date(2026, 2, 13), date(2026, 2, 19), "설날 연휴"), # 추석 2026-10-02 ~ 10-04 (date(2026, 9, 30), date(2026, 10, 6), "추석 연휴"), ] # 연도별 캘린더 맵 _FOMC_CALENDAR = {2025: _FOMC_DATES_2025, 2026: _FOMC_DATES_2026} _BOK_CALENDAR = {2025: _BOK_DATES_2025, 2026: _BOK_DATES_2026} _CPI_CALENDAR = {2026: _US_CPI_DATES_2026} def _get_fomc_dates(year: int) -> List[date]: return _FOMC_CALENDAR.get(year, []) def _get_bok_dates(year: int) -> List[date]: return _BOK_CALENDAR.get(year, []) def _get_us_cpi_dates(year: int) -> List[date]: return _CPI_CALENDAR.get(year, []) def _get_nth_weekday_of_month(year: int, month: int, weekday: int, n: int) -> date: """월의 n번째 특정 요일 날짜 반환. Args: weekday: 0=월, 1=화, ..., 4=금 n: 1=첫째, 2=둘째, 3=셋째 ... """ first_day = date(year, month, 1) # 해당 월 첫 번째 weekday까지의 차이 diff = (weekday - first_day.weekday()) % 7 first_target = first_day + timedelta(days=diff) return first_target + timedelta(weeks=n - 1) def _get_kr_options_expiry_dates(year: int) -> List[date]: """코스피200 옵션 만기일: 매월 둘째 목요일.""" dates = [] for month in range(1, 13): try: d = _get_nth_weekday_of_month(year, month, 3, 2) # 목=3, 둘째=2 dates.append(d) except ValueError: pass return dates def _get_quad_witching_dates(year: int) -> List[date]: """쿼드러플 위칭: 3, 6, 9, 12월 셋째 금요일.""" dates = [] for month in [3, 6, 9, 12]: try: d = _get_nth_weekday_of_month(year, month, 4, 3) # 금=4, 셋째=3 dates.append(d) except ValueError: pass return dates def _get_us_employment_dates(year: int) -> List[date]: """미국 비농업 고용지표: 매월 첫째 금요일.""" dates = [] for month in range(1, 13): try: d = _get_nth_weekday_of_month(year, month, 4, 1) # 금=4, 첫째=1 dates.append(d) except ValueError: pass return dates def _is_earnings_season(d: date) -> bool: """실적 시즌 판별: 1, 4, 7, 10월의 10일 ~ 말일.""" return d.month in (1, 4, 7, 10) and d.day >= 10 def _is_window_dressing_period(d: date) -> bool: """윈도 드레싱 기간: 분기 말 마지막 5영업일 (대략 25일 이후).""" return d.month in (3, 6, 9, 12) and d.day >= 25 def _is_tax_loss_selling_period(d: date) -> bool: """세금 매도 기간: 12월 10일 ~ 31일.""" return d.month == 12 and d.day >= 10 def _is_ex_dividend_period(d: date) -> bool: """배당락일 근처: 12월 26일 ~ 31일.""" return d.month == 12 and d.day >= 26 def _is_golden_week_nearby(d: date, margin_days: int = 3) -> Optional[str]: """연휴(설날/추석) 전후 margin_days 이내인지 확인.""" year = d.year # 현재 연도용 캘린더만 체크 (향후 확장 가능) weeks = _GOLDEN_WEEK_2026 if year == 2026 else [] for start, end, name in weeks: # 연휴 전후 margin 기간 pre_start = start - timedelta(days=margin_days) post_end = end + timedelta(days=margin_days) if pre_start <= d <= post_end: return name return None # ═══════════════════════════════════════════════════════════════════════════════ # 3. 이벤트별 플레이북 룰 # ═══════════════════════════════════════════════════════════════════════════════ # 각 이벤트 유형 + 단계별 행동 지침 # (position_multiplier, entry_allowed, daytrading_allowed) _PLAYBOOK_RULES: dict[tuple[MarketEventType, EventPhase], tuple[float, bool, bool, str]] = { # ── FOMC ── # D-2 ~ D-1: 신규 진입 축소 50% (MarketEventType.FOMC_MEETING, EventPhase.PRE): ( 0.5, True, True, "FOMC D-{days}: 신규 진입 50% 축소" ), # 당일: 진입 차단 (MarketEventType.FOMC_MEETING, EventPhase.DAY_OF): ( 0.0, False, False, "FOMC 당일: 전면 진입 차단" ), # D+1: 정상 (결과 반영은 market_analyzer 국면 판단에 위임) (MarketEventType.FOMC_MEETING, EventPhase.POST): ( 0.8, True, True, "FOMC D+1: 결과 반영 대기, 소폭 축소" ), # ── 한은 금통위 ── (MarketEventType.BOK_MEETING, EventPhase.PRE): ( 0.5, True, True, "한은 금통위 D-1: 신규 진입 50% 축소" ), (MarketEventType.BOK_MEETING, EventPhase.DAY_OF): ( 0.5, True, False, "한은 금통위 당일: 데이트레이딩 차단, 스윙 50% 축소" ), (MarketEventType.BOK_MEETING, EventPhase.POST): ( 1.0, True, True, "한은 금통위 D+1: 정상" ), # ── 쿼드러플 위칭 ── (MarketEventType.QUAD_WITCHING, EventPhase.PRE): ( 0.5, True, True, "쿼드러플 위칭 D-1: 변동성 확대 예상, 포지션 50% 축소" ), (MarketEventType.QUAD_WITCHING, EventPhase.DAY_OF): ( 0.3, True, True, "쿼드러플 위칭 당일: 데이트레이딩만 허용, 스윙 진입 축소" ), (MarketEventType.QUAD_WITCHING, EventPhase.POST): ( 1.0, True, True, "쿼드러플 위칭 D+1: 정상" ), # ── KR 옵션 만기 ── (MarketEventType.KR_OPTIONS_EXPIRY, EventPhase.PRE): ( 0.7, True, True, "옵션 만기 D-1: 변동성 확대 예상, 포지션 30% 축소" ), (MarketEventType.KR_OPTIONS_EXPIRY, EventPhase.DAY_OF): ( 0.5, True, True, "옵션 만기 당일: 포지션 50% 축소, 데이트레이딩 허용" ), (MarketEventType.KR_OPTIONS_EXPIRY, EventPhase.POST): ( 1.0, True, True, "옵션 만기 D+1: 정상" ), # ── 실적 시즌 ── (MarketEventType.EARNINGS_SEASON, EventPhase.DAY_OF): ( 0.7, True, True, "실적 시즌: 실적 미발표 종목 진입 주의, 포지션 30% 축소" ), # ── 배당락일 ── (MarketEventType.EX_DIVIDEND, EventPhase.DAY_OF): ( 0.5, True, False, "배당락일: 배당락 하락 예상, 데이트레이딩 차단" ), # ── 윈도 드레싱 ── (MarketEventType.WINDOW_DRESSING, EventPhase.DAY_OF): ( 0.8, True, True, "윈도 드레싱: 분기 말 수급 왜곡 주의" ), # ── 세금 매도 ── (MarketEventType.TAX_LOSS_SELLING, EventPhase.DAY_OF): ( 0.7, True, True, "세금 매도 시즌: 손실 종목 투매 압력 주의" ), # ── 연휴 (설날/추석) ── (MarketEventType.GOLDEN_WEEK, EventPhase.PRE): ( 0.5, True, False, "연휴 전: 포지션 축소, 데이트레이딩 차단" ), (MarketEventType.GOLDEN_WEEK, EventPhase.POST): ( 0.8, True, True, "연휴 후: 갭 리스크 대비, 소폭 축소" ), # ── 미국 CPI ── (MarketEventType.US_CPI, EventPhase.PRE): ( 0.7, True, True, "미국 CPI D-1: 변동성 대비 30% 축소" ), (MarketEventType.US_CPI, EventPhase.DAY_OF): ( 0.5, True, False, "미국 CPI 당일: 데이트레이딩 차단 (발표 후 급변동)" ), # ── 미국 고용지표 ── (MarketEventType.US_EMPLOYMENT, EventPhase.PRE): ( 0.7, True, True, "미국 고용지표 D-1: 변동성 대비 30% 축소" ), (MarketEventType.US_EMPLOYMENT, EventPhase.DAY_OF): ( 0.5, True, False, "미국 고용지표 당일: 데이트레이딩 차단" ), # ── 서킷브레이커 (실시간) ── (MarketEventType.CIRCUIT_BREAKER, EventPhase.ACTIVE): ( 0.0, False, False, "서킷브레이커 발동: 모든 매매 즉시 중단" ), # ── VI 발동 (실시간) ── (MarketEventType.VI_TRIGGERED, EventPhase.ACTIVE): ( 0.5, True, False, "VI 발동: 해당 종목 데이트레이딩 차단, 전체 축소" ), } # ═══════════════════════════════════════════════════════════════════════════════ # 4. 실시간 이벤트 상태 (메모리) # ═══════════════════════════════════════════════════════════════════════════════ class _RealtimeState: """실시간 이벤트 상태 관리 (인메모리).""" def __init__(self): self.circuit_breaker_active: bool = False self.circuit_breaker_triggered_at: Optional[float] = None self.circuit_breaker_resume_after: float = 1800.0 # 30분 후 재개 검토 self.vi_stocks: dict[str, float] = {} # stock_code -> triggered_at self.vi_cooldown: float = 600.0 # VI 발동 후 10분 쿨다운 self.alerts: List[RealtimeAlert] = [] self._max_alerts = 100 def trigger_circuit_breaker(self, description: str = "") -> None: """서킷브레이커 발동.""" self.circuit_breaker_active = True self.circuit_breaker_triggered_at = time.monotonic() alert = RealtimeAlert( event_type=MarketEventType.CIRCUIT_BREAKER, description=description or "서킷브레이커 발동", severity="critical", timestamp=datetime.now(KST).replace(tzinfo=None), ) self._add_alert(alert) logger.critical(f"[Playbook] CIRCUIT BREAKER: {description}") def clear_circuit_breaker(self) -> None: """서킷브레이커 해제.""" self.circuit_breaker_active = False self.circuit_breaker_triggered_at = None logger.info("[Playbook] CIRCUIT BREAKER 해제") def is_circuit_breaker_active(self) -> bool: """서킷브레이커가 활성 상태인지 확인. 30분 경과 시 자동 해제.""" if not self.circuit_breaker_active: return False if self.circuit_breaker_triggered_at is not None: elapsed = time.monotonic() - self.circuit_breaker_triggered_at if elapsed > self.circuit_breaker_resume_after: self.clear_circuit_breaker() logger.info("[Playbook] 서킷브레이커 30분 경과 — 자동 해제") return False return True def trigger_vi(self, stock_code: str, description: str = "") -> None: """VI(변동성 완화장치) 발동 기록.""" self.vi_stocks[stock_code] = time.monotonic() alert = RealtimeAlert( event_type=MarketEventType.VI_TRIGGERED, description=description or f"VI 발동: {stock_code}", severity="warning", timestamp=datetime.now(KST).replace(tzinfo=None), data={"stock_code": stock_code}, ) self._add_alert(alert) logger.warning(f"[Playbook] VI 발동: {stock_code}") def is_vi_active(self, stock_code: str) -> bool: """해당 종목의 VI 쿨다운이 활성 상태인지 확인.""" triggered_at = self.vi_stocks.get(stock_code) if triggered_at is None: return False if time.monotonic() - triggered_at > self.vi_cooldown: del self.vi_stocks[stock_code] return False return True def get_vi_active_count(self) -> int: """현재 VI 활성 종목 수 (만료된 항목은 정리).""" now = time.monotonic() expired = [k for k, v in self.vi_stocks.items() if now - v > self.vi_cooldown] for k in expired: del self.vi_stocks[k] return len(self.vi_stocks) def _add_alert(self, alert: RealtimeAlert) -> None: self.alerts.append(alert) if len(self.alerts) > self._max_alerts: self.alerts = self.alerts[-self._max_alerts:] # 싱글톤 인스턴스 _realtime_state = _RealtimeState() # ═══════════════════════════════════════════════════════════════════════════════ # 5. 캐시 # ═══════════════════════════════════════════════════════════════════════════════ _playbook_cache: dict = {"result": None, "date": None} # ═══════════════════════════════════════════════════════════════════════════════ # 6. 핵심 공개 API # ═══════════════════════════════════════════════════════════════════════════════ def get_upcoming_events(days: int = 7) -> List[MarketEvent]: """향후 N일 이내 예정된 시장 이벤트 목록. 규칙 기반 계산(옵션 만기, 고용지표 등) + 하드코딩 캘린더(FOMC, 한은) 조합. """ today = datetime.now(KST).date() end_date = today + timedelta(days=days) year = today.year events: List[MarketEvent] = [] # FOMC for d in _get_fomc_dates(year): diff = (d - today).days if -1 <= diff <= days: phase = _classify_phase(today, d, pre_days=2, post_days=1) events.append(MarketEvent( event_type=MarketEventType.FOMC_MEETING, event_date=d, description=f"FOMC 금리 결정 ({d.isoformat()})", phase=phase, days_until=diff, )) # 한은 금통위 for d in _get_bok_dates(year): diff = (d - today).days if -1 <= diff <= days: phase = _classify_phase(today, d, pre_days=1, post_days=1) events.append(MarketEvent( event_type=MarketEventType.BOK_MEETING, event_date=d, description=f"한국은행 금통위 ({d.isoformat()})", phase=phase, days_until=diff, )) # 코스피200 옵션 만기 (매월 둘째 목요일) for d in _get_kr_options_expiry_dates(year): diff = (d - today).days if -1 <= diff <= days: phase = _classify_phase(today, d, pre_days=1, post_days=1) events.append(MarketEvent( event_type=MarketEventType.KR_OPTIONS_EXPIRY, event_date=d, description=f"코스피200 옵션 만기 ({d.isoformat()})", phase=phase, days_until=diff, )) # 쿼드러플 위칭 (3,6,9,12월 셋째 금요일) for d in _get_quad_witching_dates(year): diff = (d - today).days if -1 <= diff <= days: phase = _classify_phase(today, d, pre_days=1, post_days=1) events.append(MarketEvent( event_type=MarketEventType.QUAD_WITCHING, event_date=d, description=f"쿼드러플 위칭 ({d.isoformat()})", phase=phase, days_until=diff, )) # 미국 CPI for d in _get_us_cpi_dates(year): diff = (d - today).days if -1 <= diff <= days: phase = _classify_phase(today, d, pre_days=1, post_days=0) events.append(MarketEvent( event_type=MarketEventType.US_CPI, event_date=d, description=f"미국 CPI 발표 ({d.isoformat()})", phase=phase, days_until=diff, )) # 미국 고용지표 (매월 첫째 금요일) for d in _get_us_employment_dates(year): diff = (d - today).days if -1 <= diff <= days: phase = _classify_phase(today, d, pre_days=1, post_days=0) events.append(MarketEvent( event_type=MarketEventType.US_EMPLOYMENT, event_date=d, description=f"미국 비농업 고용지표 ({d.isoformat()})", phase=phase, days_until=diff, )) # 실적 시즌 (1,4,7,10월 10일~말일) if _is_earnings_season(today): events.append(MarketEvent( event_type=MarketEventType.EARNINGS_SEASON, event_date=today, description=f"실적 발표 시즌 ({today.month}월)", phase=EventPhase.DAY_OF, days_until=0, )) # 윈도 드레싱 (분기 말) if _is_window_dressing_period(today): events.append(MarketEvent( event_type=MarketEventType.WINDOW_DRESSING, event_date=today, description=f"윈도 드레싱 기간 ({today.month}월 말)", phase=EventPhase.DAY_OF, days_until=0, )) # 세금 매도 (12월) if _is_tax_loss_selling_period(today): events.append(MarketEvent( event_type=MarketEventType.TAX_LOSS_SELLING, event_date=today, description="세금 손실 매도 시즌 (12월)", phase=EventPhase.DAY_OF, days_until=0, )) # 배당락일 (12월 말) if _is_ex_dividend_period(today): events.append(MarketEvent( event_type=MarketEventType.EX_DIVIDEND, event_date=today, description="배당락일 (12월 말)", phase=EventPhase.DAY_OF, days_until=0, )) # 연휴 (설날/추석) golden = _is_golden_week_nearby(today) if golden: events.append(MarketEvent( event_type=MarketEventType.GOLDEN_WEEK, event_date=today, description=f"{golden} 전후", phase=EventPhase.PRE if today < date(today.year, today.month, today.day) else EventPhase.DAY_OF, days_until=0, )) # 날짜순 정렬 events.sort(key=lambda e: (e.event_date, e.event_type.value)) return events def get_today_playbook() -> PlaybookAction: """오늘 적용할 행동 지침 결정. 여러 이벤트가 겹칠 경우 가장 보수적인(낮은 multiplier, False 우선) 값을 적용. 결과는 당일 캐시. """ today = datetime.now(KST).date() # 캐시 확인 (당일 유효, 실시간 이벤트 제외) if _playbook_cache["date"] == today and _playbook_cache["result"] is not None: cached: PlaybookAction = _playbook_cache["result"] # 실시간 이벤트는 항상 다시 체크 _apply_realtime_overlay(cached) return cached events = get_upcoming_events(days=7) today_events = [e for e in events if e.phase != EventPhase.NONE] if not today_events: action = PlaybookAction( position_multiplier=1.0, entry_allowed=True, daytrading_allowed=True, reason="이벤트 없음 - 정상 운영", active_events=[], ) # 실시간 이벤트 오버레이 _apply_realtime_overlay(action) _playbook_cache["result"] = action _playbook_cache["date"] = today return action # 여러 이벤트를 merge: 가장 보수적인 값 선택 min_multiplier = 1.0 entry_allowed = True daytrading_allowed = True reasons = [] for event in today_events: key = (event.event_type, event.phase) rule = _PLAYBOOK_RULES.get(key) if rule is None: continue mult, entry, daytrade, reason_template = rule # D-{days} 플레이스홀더 치환 reason_str = reason_template.replace("{days}", str(abs(event.days_until))) if mult < min_multiplier: min_multiplier = mult if not entry: entry_allowed = False if not daytrade: daytrading_allowed = False reasons.append(reason_str) action = PlaybookAction( position_multiplier=min_multiplier, entry_allowed=entry_allowed, daytrading_allowed=daytrading_allowed, reason=" | ".join(reasons) if reasons else "이벤트 감지됨 (규칙 없음)", active_events=today_events, ) # 실시간 이벤트 오버레이 _apply_realtime_overlay(action) _playbook_cache["result"] = action _playbook_cache["date"] = today logger.info( f"[Playbook] 오늘 플레이북: mult={action.position_multiplier:.1f}, " f"entry={action.entry_allowed}, dt={action.daytrading_allowed}, " f"reason={action.reason}" ) return action def check_realtime_events() -> List[RealtimeAlert]: """실시간 이벤트 알림 목록 반환 (서킷브레이커, VI 등).""" alerts: List[RealtimeAlert] = [] if _realtime_state.is_circuit_breaker_active(): alerts.append(RealtimeAlert( event_type=MarketEventType.CIRCUIT_BREAKER, description="서킷브레이커 활성 상태", severity="critical", )) vi_count = _realtime_state.get_vi_active_count() if vi_count > 0: alerts.append(RealtimeAlert( event_type=MarketEventType.VI_TRIGGERED, description=f"VI 발동 종목 {vi_count}개", severity="warning", data={"count": vi_count, "stocks": list(_realtime_state.vi_stocks.keys())}, )) return alerts # ═══════════════════════════════════════════════════════════════════════════════ # 7. 실시간 이벤트 제어 함수 (외부에서 호출) # ═══════════════════════════════════════════════════════════════════════════════ def trigger_circuit_breaker(description: str = "시장 서킷브레이커 발동") -> None: """서킷브레이커 발동 (kis_realtime_stream 등에서 호출).""" _realtime_state.trigger_circuit_breaker(description) # 캐시 무효화 (실시간 이벤트 즉시 반영) _playbook_cache["result"] = None def clear_circuit_breaker() -> None: """서킷브레이커 해제.""" _realtime_state.clear_circuit_breaker() _playbook_cache["result"] = None def trigger_vi(stock_code: str, description: str = "") -> None: """VI 발동 기록.""" _realtime_state.trigger_vi(stock_code, description) def is_vi_active(stock_code: str) -> bool: """해당 종목의 VI 쿨다운 활성 여부.""" return _realtime_state.is_vi_active(stock_code) def is_circuit_breaker_active() -> bool: """서킷브레이커 활성 여부.""" return _realtime_state.is_circuit_breaker_active() def get_realtime_state_summary() -> dict: """실시간 상태 요약 (API 노출용).""" return { "circuit_breaker_active": _realtime_state.is_circuit_breaker_active(), "vi_active_count": _realtime_state.get_vi_active_count(), "vi_stocks": list(_realtime_state.vi_stocks.keys()), "recent_alerts": [ { "type": a.event_type.value, "description": a.description, "severity": a.severity, "timestamp": a.timestamp.isoformat() if a.timestamp else None, } for a in _realtime_state.alerts[-10:] ], } # ═══════════════════════════════════════════════════════════════════════════════ # 8. 통합 헬퍼 (market_analyzer, entry_service에서 사용) # ═══════════════════════════════════════════════════════════════════════════════ def apply_playbook_to_regime(regime_result: dict) -> dict: """market_analyzer.check_market_regime() 결과에 playbook 오버레이. 기존 regime_result를 변경하지 않고 새 dict를 반환. playbook.position_multiplier를 기존 multiplier에 곱하고, playbook.entry_allowed=False면 regime을 'event_blocked'로 오버라이드. Args: regime_result: check_market_regime() 반환 dict Returns: playbook이 적용된 새 dict """ try: playbook = get_today_playbook() except Exception as e: warn_throttled(logger, "market_playbook:821", f"[Playbook] 플레이북 조회 실패 (원본 유지): {e}") return regime_result result = dict(regime_result) original_multiplier = result.get("position_multiplier", 1.0) # position_multiplier 곱셈 적용 result["position_multiplier"] = round( original_multiplier * playbook.position_multiplier, 2 ) # 진입 차단 시 regime 오버라이드 if not playbook.entry_allowed: result["regime"] = "event_blocked" result["max_trades"] = 0 result["min_score"] = 9999 # 플레이북 메타 정보 추가 result["playbook"] = playbook.to_dict() result["playbook_applied"] = True if playbook.position_multiplier < 1.0 or not playbook.entry_allowed: logger.info( f"[Playbook] regime 오버레이: {regime_result.get('regime')} -> " f"mult={result['position_multiplier']}, entry={playbook.entry_allowed}, " f"reason={playbook.reason}" ) return result def is_daytrading_allowed_by_playbook() -> bool: """데이트레이딩 허용 여부 (플레이북 기준). daytrading/entry_service.scan_and_enter()에서 사전 체크용. """ try: playbook = get_today_playbook() return playbook.daytrading_allowed except Exception: return True # 플레이북 오류 시 기본 허용 def is_entry_allowed_by_playbook() -> bool: """신규 진입 허용 여부 (플레이북 기준). swing/entry_service에서 사전 체크용. """ try: playbook = get_today_playbook() return playbook.entry_allowed except Exception: return True def get_position_multiplier_from_playbook() -> float: """플레이북 포지션 배율 (0.0 ~ 1.0). 포지션 사이징에서 budget *= get_position_multiplier_from_playbook() """ try: playbook = get_today_playbook() return playbook.position_multiplier except Exception: return 1.0 # ═══════════════════════════════════════════════════════════════════════════════ # 내부 헬퍼 # ═══════════════════════════════════════════════════════════════════════════════ def _classify_phase( today: date, event_date: date, pre_days: int = 2, post_days: int = 1, ) -> EventPhase: """오늘과 이벤트 날짜의 관계에 따라 단계 분류.""" diff = (event_date - today).days if diff == 0: return EventPhase.DAY_OF elif 0 < diff <= pre_days: return EventPhase.PRE elif -post_days <= diff < 0: return EventPhase.POST return EventPhase.NONE def _apply_realtime_overlay(action: PlaybookAction) -> None: """실시간 이벤트(서킷브레이커, VI)를 PlaybookAction에 오버레이.""" if _realtime_state.is_circuit_breaker_active(): action.position_multiplier = 0.0 action.entry_allowed = False action.daytrading_allowed = False if "서킷브레이커" not in action.reason: action.reason = f"서킷브레이커 발동 | {action.reason}" if action.reason else "서킷브레이커 발동" # VI 종목이 3개 이상이면 전체 축소 vi_count = _realtime_state.get_vi_active_count() if vi_count >= 3: action.position_multiplier = min(action.position_multiplier, 0.5) action.daytrading_allowed = False if "VI" not in action.reason: reason_addition = f"VI 다수 발동({vi_count}종목)" action.reason = f"{reason_addition} | {action.reason}" if action.reason else reason_addition