- 스윙 체결가 확인 전 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
436 lines
15 KiB
Python
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,
|
|
}
|