""" 종합 시장 상황 대시보드 API - GET /dashboard: 시장 국면, 리스크 상태, 포트폴리오 요약 통합 조회 - GET /events: 향후 7일 이벤트 캘린더 - GET /news: 종목별 뉴스 + 감성 점수 """ import logging import pytz from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, Query from app.core.config import settings from app.core.json_helpers import _dumps logger = logging.getLogger(__name__) router = APIRouter() KST = pytz.timezone("Asia/Seoul") def _get_kst_now() -> datetime: return datetime.now(KST) # --------------------------------------------------------------------------- # Helpers: 안전하게 외부 모듈 호출 (미설치/미구현 시 빈 결과 반환) # --------------------------------------------------------------------------- async def _get_market_regime() -> dict: """market_analyzer.check_market_regime() 호출. 실패 시 빈 결과.""" try: from app.services.market_analyzer import check_market_regime raw = await check_market_regime() regime = raw.get("regime", "unknown") hmm_ensemble = raw.get("hmm_ensemble") or {} return { "rule_based": regime, "hmm": hmm_ensemble.get("hmm_regime"), "ensemble": hmm_ensemble.get("regime"), "confidence": hmm_ensemble.get("confidence"), "position_multiplier": raw.get("position_multiplier", 0.5), "max_trades": raw.get("max_trades"), "message": raw.get("message", ""), "consecutive_down": raw.get("consecutive_down"), "drop_from_ma20": raw.get("drop_from_ma20"), } except Exception as e: logger.warning("market_regime 조회 실패: %s", e) return { "rule_based": "unknown", "hmm": None, "ensemble": None, "confidence": None, "position_multiplier": 0.5, "max_trades": None, "message": f"조회 실패: {e}", } async def _get_playbook() -> dict: """market_playbook.get_today_playbook() 호출. 파일 없으면 빈 결과.""" try: from app.services.market_playbook import get_today_playbook # type: ignore return await get_today_playbook() except ImportError: return { "events_today": [], "position_multiplier": None, "entry_allowed": None, "daytrading_allowed": None, "reason": "market_playbook 모듈 미설치", } except Exception as e: logger.warning("playbook 조회 실패: %s", e) return { "events_today": [], "position_multiplier": None, "entry_allowed": None, "daytrading_allowed": None, "reason": f"조회 실패: {e}", } async def _get_news_sentiment(stock_code: Optional[str] = None) -> dict: """news_sentiment.get_market_news_score() 호출. 파일 없으면 빈 결과.""" try: from app.services.news_sentiment import ( # type: ignore get_market_news_score, get_stock_news_score, ) if stock_code: score = float(await get_stock_news_score(stock_code)) else: score = float(await get_market_news_score()) return { "market_score": score, "top_positive": [], "top_negative": [], "reason": None, } except ImportError: return { "market_score": None, "top_positive": [], "top_negative": [], "reason": "news_sentiment 모듈 미설치", } except Exception as e: logger.warning("news_sentiment 조회 실패: %s", e) return { "market_score": None, "top_positive": [], "top_negative": [], "reason": f"조회 실패: {e}", } async def _get_risk_status() -> dict: """mdd_manager + loss_tracker 에서 리스크 상태 수집.""" from app.services.mdd_manager import mdd_manager from app.services.loss_tracker import loss_tracker # 현재 MDD 계산 peak = mdd_manager.peak_balance if peak > 0: # 마지막으로 알려진 잔고가 없으므로 peak 기반 drawdown만 표시 # portfolio_summary에서 total_assets가 있으면 호출측에서 보정 가능 mdd_current = None # 호출측에서 portfolio 데이터로 계산 else: mdd_current = 0.0 # [BUG FIX] _tracker 직접 접근 -> Lock 보호된 get_all_bans_safe() 사용 banned_stocks = await loss_tracker.get_all_bans_safe() return { "mdd_peak": peak, "mdd_current_pct": mdd_current, "is_paused": mdd_manager.is_paused, "is_short_term_paused": mdd_manager.is_short_term_strategy_paused, "reduction_applied": mdd_manager.reduction_applied, "banned_stocks": banned_stocks, "banned_count": len(banned_stocks), } def _get_portfolio_summary() -> dict: """portfolio_cache에서 요약 정보 수집.""" try: from app.api.endpoints.portfolio import portfolio_cache summary = portfolio_cache.get_summary_copy() if summary is None: return { "total_assets": None, "available_cash": None, "total_value": None, "total_investment": None, "total_profit_loss": None, "total_profit_loss_rate": None, "holdings_count": 0, "last_synced": None, "reason": "포트폴리오 미동기화", } last_synced = portfolio_cache.last_synced return { "total_assets": summary.get("total_assets"), "available_cash": summary.get("available_cash"), "total_value": summary.get("total_value"), "total_investment": summary.get("total_investment"), "total_profit_loss": summary.get("total_profit_loss"), "total_profit_loss_rate": summary.get("total_profit_loss_rate"), "holdings_count": summary.get("total_stocks", 0), "last_synced": last_synced.isoformat() if last_synced else None, } except Exception as e: logger.warning("portfolio_summary 조회 실패: %s", e) return { "total_assets": None, "available_cash": None, "holdings_count": 0, "reason": f"조회 실패: {e}", } def _get_active_strategies(market_regime: dict, risk_status: dict) -> dict: """현재 활성화된 전략 상태 판별.""" reasons = [] # 기본 활성화 상태 swing_enabled = settings.AUTO_TRADE_ENABLED daytrading_enabled = settings.DAY_TRADE_ENABLED short_term_enabled = settings.AUTO_TRADE_ENABLED us_trading_enabled = settings.US_TRADING_ENABLED # TRADING_HALTED 전역 정지 if settings.TRADING_HALTED: swing_enabled = False daytrading_enabled = False short_term_enabled = False reasons.append("TRADING_HALTED=true (수동 비상 정지)") # 시장 국면 필터: SIDEWAYS/crash/halted 시 진입 차단 regime = market_regime.get("rule_based", "unknown") if regime in ("sideways", "crash", "halted"): swing_enabled = False daytrading_enabled = False short_term_enabled = False reasons.append(f"시장 국면 '{regime}' - 전체 진입 차단") # MDD 기반 차단 if risk_status.get("is_paused"): swing_enabled = False daytrading_enabled = False short_term_enabled = False reasons.append("MDD 한도 초과 - 신규 진입 중단") if risk_status.get("is_short_term_paused"): daytrading_enabled = False short_term_enabled = False if "MDD" not in " ".join(reasons): reasons.append("MDD -10% 초과 - 단기/데이트레이딩 중단") return { "swing": swing_enabled, "daytrading": daytrading_enabled, "short_term": short_term_enabled, "us_trading": us_trading_enabled, "reasons": reasons if reasons else ["정상 운영 중"], } # --------------------------------------------------------------------------- # 1) GET /dashboard -- 종합 대시보드 # --------------------------------------------------------------------------- @router.get("/dashboard") async def get_market_intelligence_dashboard(): """종합 시장 상황 대시보드. 시장 국면, 플레이북, 뉴스 감성, 리스크 상태, 포트폴리오 요약, 활성 전략 정보를 한 번에 반환합니다. """ # 병렬로 가져올 수 있는 항목은 동시 실행 import asyncio market_regime_task = asyncio.create_task(_get_market_regime()) playbook_task = asyncio.create_task(_get_playbook()) news_task = asyncio.create_task(_get_news_sentiment()) market_regime = await market_regime_task playbook = await playbook_task news_sentiment = await news_task risk_status = await _get_risk_status() portfolio_summary = _get_portfolio_summary() # MDD 현재 비율 계산 (portfolio 데이터가 있을 때) total_assets = portfolio_summary.get("total_assets") peak = risk_status.get("mdd_peak", 0) if total_assets and peak and peak > 0: risk_status["mdd_current_pct"] = round( ((total_assets - peak) / peak) * 100, 2 ) # 활성 전략 판별 active_strategies = _get_active_strategies(market_regime, risk_status) now = _get_kst_now() return { "timestamp": now.isoformat(), "market_regime": market_regime, "playbook": playbook, "news_sentiment": news_sentiment, "risk_status": risk_status, "portfolio_summary": portfolio_summary, "active_strategies": active_strategies, "config_snapshot": { "kis_mode": settings.KIS_MODE, "auto_trade_enabled": settings.AUTO_TRADE_ENABLED, "day_trade_enabled": settings.DAY_TRADE_ENABLED, "us_trading_enabled": settings.US_TRADING_ENABLED, "trading_halted": settings.TRADING_HALTED, "max_position_size": settings.MAX_POSITION_SIZE, "max_holdings_count": settings.MAX_HOLDINGS_COUNT, }, } # --------------------------------------------------------------------------- # 2) GET /events -- 향후 7일 이벤트 캘린더 # --------------------------------------------------------------------------- # 한국 주식시장 정기 이벤트 (요일 기반 + 특정일) _KNOWN_EVENTS = { # 매월 둘째 목요일: 옵션 만기일 (간소화 -- 실제로는 달력 계산 필요) "options_expiry_weekday": 3, # Thursday = 3 "options_expiry_week": 2, # 둘째 주 } def _get_options_expiry_date(year: int, month: int) -> Optional[datetime]: """해당 월의 옵션 만기일 (둘째 목요일) 계산.""" from calendar import monthcalendar cal = monthcalendar(year, month) thursdays = [week[3] for week in cal if week[3] != 0] if len(thursdays) >= 2: return datetime(year, month, thursdays[1], tzinfo=KST) return None def _get_events_for_range(start: datetime, days: int = 7) -> list: """start부터 days일간 이벤트 목록 반환.""" events = [] for offset in range(days): d = start + timedelta(days=offset) date_str = d.strftime("%Y-%m-%d") weekday = d.weekday() # 주말 if weekday >= 5: events.append({ "date": date_str, "event": "weekend", "description": "주말 (장 휴무)", "impact": "none", }) continue day_events = [] # 옵션 만기일 체크 expiry = _get_options_expiry_date(d.year, d.month) if expiry and expiry.date() == d.date(): day_events.append({ "event": "kr_options_expiry", "description": "옵션 만기일 -- 변동성 확대 주의, 데이트레이딩 차단 권장", "impact": "high", }) # 월말 (리밸런싱 수요) next_day = d + timedelta(days=1) if next_day.month != d.month: day_events.append({ "event": "month_end", "description": "월말 -- 기관/펀드 리밸런싱 수요", "impact": "medium", }) # 월초 (1~3일) if d.day <= 3: day_events.append({ "event": "month_start", "description": "월초 -- 자금 유입 기대", "impact": "low", }) if day_events: for evt in day_events: events.append({"date": date_str, **evt}) else: events.append({ "date": date_str, "event": "normal", "description": "정상 거래일", "impact": "none", }) return events @router.get("/events") async def get_market_events( days: int = Query(default=7, ge=1, le=30, description="조회 기간 (일)"), ): """향후 N일간 시장 이벤트 캘린더. 옵션 만기일, 월말/월초 등 정기 이벤트를 반환합니다. market_playbook 모듈이 설치되어 있으면 추가 이벤트를 병합합니다. """ now = _get_kst_now() base_events = _get_events_for_range(now, days) # market_playbook이 있으면 추가 이벤트 병합 try: from app.services.market_playbook import get_events_calendar # type: ignore extra = await get_events_calendar(days=days) if extra: # 날짜 기준으로 병합 existing_dates = {e["date"] for e in base_events if e.get("event") != "normal"} for evt in extra: if evt.get("date") not in existing_dates: base_events.append(evt) base_events.sort(key=lambda e: e["date"]) except ImportError: pass except Exception as e: logger.warning("playbook events 조회 실패: %s", e) return { "timestamp": now.isoformat(), "range_start": now.strftime("%Y-%m-%d"), "range_end": (now + timedelta(days=days - 1)).strftime("%Y-%m-%d"), "events": base_events, } # --------------------------------------------------------------------------- # 3) GET /news -- 종목별 뉴스 + 감성 점수 # --------------------------------------------------------------------------- @router.get("/news") async def get_market_news( stock_code: Optional[str] = Query( default=None, description="종목코드 (없으면 전체 시장 뉴스)", ), ): """종목별 또는 전체 시장 뉴스 + 감성 점수. news_sentiment 모듈이 설치되어 있지 않으면 빈 결과를 반환합니다. """ now = _get_kst_now() result = await _get_news_sentiment(stock_code=stock_code) return { "timestamp": now.isoformat(), "stock_code": stock_code, **result, }