## 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>
926 lines
34 KiB
Python
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
|