Files
auto-stock/backend/app/api/endpoints/market_intelligence.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

436 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 ( # 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,
}