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:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -0,0 +1,423 @@
"""
종합 시장 상황 대시보드 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,
}