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>
This commit is contained in:
925
backend/app/services/market_playbook.py
Normal file
925
backend/app/services/market_playbook.py
Normal file
@@ -0,0 +1,925 @@
|
||||
"""시장 상황별 대응 플레이북 (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
|
||||
Reference in New Issue
Block a user