## 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>
424 lines
15 KiB
Python
424 lines
15 KiB
Python
"""
|
|
종합 시장 상황 대시보드 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 get_market_news_score # type: ignore
|
|
return await get_market_news_score(stock_code=stock_code)
|
|
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,
|
|
}
|