Files
auto-stock/backend/app/services/market_playbook.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

926 lines
34 KiB
Python

"""시장 상황별 대응 플레이북 (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))
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:
logger.debug(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