""" πŸ”₯ 리슀크 관리 및 μ„±κ³Ό 평가 API - MDD (Maximum Drawdown) 좔적 - Sharpe Ratio 계산 - 연속 손싀 μ œν•œ - 일일 손싀 ν•œλ„ - ν¬μ§€μ…˜ 사이징 μ œν•œ """ from fastapi import APIRouter, HTTPException, Query, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, func, desc from datetime import datetime, timedelta, date from decimal import Decimal from typing import List, Optional import logging import numpy as np from app.core.database import get_db from app.models.models import TradeHistory, TradingSession, Portfolio router = APIRouter() logger = logging.getLogger(__name__) # πŸ”₯ 리슀크 관리 μ„€μ • MAX_POSITION_SIZE_RATIO = 0.20 # κ³„μ’Œμ˜ 20%κΉŒμ§€λ§Œ MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손싀 μ‹œ 쀑단 MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손싀 μ‹œ 쀑단 COMMISSION_RATE = 0.00015 # 수수료 0.015% TRADE_TAX_RATE = 0.0023 # κ±°λž˜μ„Έ 0.23% (맀도 μ‹œλ§Œ) TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # 왕볡 μ•½ 0.26% @router.get("/risk-metrics") async def get_risk_metrics( days: int = Query(30, description="뢄석 κΈ°κ°„ (일)"), db: AsyncSession = Depends(get_db) ): """ πŸ“Š 리슀크 관리 λ©”νŠΈλ¦­ 쑰회 - Maximum Drawdown (MDD): μ΅œλŒ€ 손싀 깊이 - λˆ„μ  손읡 곑선 - ν˜„μž¬ κ³„μ’Œ μƒνƒœ """ try: since_date = datetime.utcnow() - timedelta(days=days) # 맀도 μ™„λ£Œλœ 거래만 쑰회 (μ‹€ν˜„ 손읡) result = await db.execute( select(TradeHistory) .where(TradeHistory.created_at >= since_date) .where(TradeHistory.order_type == "SELL") .where(TradeHistory.status.in_(["EXECUTED", "FILLED"])) .order_by(TradeHistory.executed_at) ) trades = result.scalars().all() if not trades: return { "max_drawdown": 0, "max_drawdown_pct": 0, "current_equity": 0, "peak_equity": 0, "trades_count": 0, "message": "거래 내역이 μ—†μŠ΅λ‹ˆλ‹€" } # λˆ„μ  손읡 계산 cumulative_pl = 0 peak = 0 max_drawdown = 0 equity_curve = [] drawdown_curve = [] for trade in trades: # μˆœμ†μ΅ μ‚¬μš© (μ—†μœΌλ©΄ total_amount μ‚¬μš©) pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0 cumulative_pl += pl equity_curve.append({ "date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(), "equity": cumulative_pl }) # 졜고점 κ°±μ‹  if cumulative_pl > peak: peak = cumulative_pl # Drawdown 계산 drawdown = peak - cumulative_pl drawdown_pct = (drawdown / peak * 100) if peak > 0 else 0 drawdown_curve.append({ "date": trade.executed_at.isoformat() if trade.executed_at else trade.created_at.isoformat(), "drawdown": drawdown, "drawdown_pct": round(drawdown_pct, 2) }) if drawdown > max_drawdown: max_drawdown = drawdown max_drawdown_pct = (max_drawdown / peak * 100) if peak > 0 else 0 # MDD μœ„ν—˜ 레벨 risk_level = "μ•ˆμ „" if max_drawdown_pct > 30: risk_level = "μœ„ν—˜ (μ‹œμŠ€ν…œ 쀑지 ꢌμž₯)" elif max_drawdown_pct > 20: risk_level = "κ²½κ³  (ν¬μ§€μ…˜ μΆ•μ†Œ ꢌμž₯)" elif max_drawdown_pct > 10: risk_level = "주의" return { "max_drawdown": round(float(max_drawdown), 2), "max_drawdown_pct": round(max_drawdown_pct, 2), "current_equity": round(cumulative_pl, 2), "peak_equity": round(peak, 2), "trades_count": len(trades), "risk_level": risk_level, "equity_curve": equity_curve[-50:], # 졜근 50개만 "drawdown_curve": drawdown_curve[-50:] } except Exception as e: logger.error(f"Error calculating risk metrics: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/sharpe-ratio") async def get_sharpe_ratio( days: int = Query(30, description="뢄석 κΈ°κ°„ (일)"), db: AsyncSession = Depends(get_db) ): """ πŸ“ˆ Sharpe Ratio 계산 수읡λ₯  λŒ€λΉ„ 변동성(리슀크)을 μΈ‘μ • - > 2.0: 맀우 우수 - > 1.0: 우수 - < 1.0: κ°œμ„  ν•„μš” """ try: since_date = datetime.utcnow() - timedelta(days=days) result = await db.execute( select(TradeHistory) .where(TradeHistory.created_at >= since_date) .where(TradeHistory.order_type == "SELL") .where(TradeHistory.status.in_(["EXECUTED", "FILLED"])) .where(TradeHistory.profit_loss_rate.isnot(None)) ) trades = result.scalars().all() if len(trades) < 2: return { "sharpe_ratio": None, "avg_return": 0, "std_return": 0, "trades_count": len(trades), "message": "κ±°λž˜κ°€ λΆ€μ‘±ν•©λ‹ˆλ‹€ (μ΅œμ†Œ 2건 ν•„μš”)" } # 각 거래의 수읡λ₯  μΆ”μΆœ returns = [float(trade.profit_loss_rate) / 100 for trade in trades if trade.profit_loss_rate] if not returns: return { "sharpe_ratio": None, "message": "수읡λ₯  데이터가 μ—†μŠ΅λ‹ˆλ‹€" } avg_return = np.mean(returns) std_return = np.std(returns) # Sharpe Ratio 계산 (μ—°κ°„ν™”: 252 거래일 κΈ°μ€€) # λ¬΄μœ„ν—˜ 수읡λ₯ μ€ 0으둜 κ°€μ • sharpe = (avg_return * np.sqrt(252)) / std_return if std_return > 0 else 0 # 평가 if sharpe > 2: interpretation = "Excellent (맀우 우수)" elif sharpe > 1: interpretation = "Good (우수)" elif sharpe > 0: interpretation = "Fair (보톡)" else: interpretation = "Poor (κ°œμ„  ν•„μš”)" return { "sharpe_ratio": round(sharpe, 3), "avg_return": round(avg_return * 100, 2), # % "std_return": round(std_return * 100, 2), # % "annualized_return": round(avg_return * 252 * 100, 2), # % "interpretation": interpretation, "trades_count": len(returns) } except Exception as e: logger.error(f"Error calculating Sharpe ratio: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/session/today") async def get_today_session(db: AsyncSession = Depends(get_db)): """ πŸ“… 였늘의 νŠΈλ ˆμ΄λ”© μ„Έμ…˜ 쑰회 - 연속 손싀/승리 좔적 - 일일 손읡 - 거래 쀑지 μƒνƒœ """ try: today = date.today() result = await db.execute( select(TradingSession) .where(TradingSession.session_date == today) ) session = result.scalar_one_or_none() if not session: # 였늘 μ„Έμ…˜μ΄ μ—†μœΌλ©΄ 생성 session = TradingSession( session_date=today, consecutive_losses=0, consecutive_wins=0, daily_profit_loss=0, daily_trades_count=0, is_paused=False ) db.add(session) await db.commit() await db.refresh(session) return { "session_date": session.session_date.isoformat(), "consecutive_losses": session.consecutive_losses, "consecutive_wins": session.consecutive_wins, "daily_profit_loss": float(session.daily_profit_loss) if session.daily_profit_loss else 0, "daily_trades_count": session.daily_trades_count, "is_paused": session.is_paused, "pause_reason": session.pause_reason, "starting_balance": float(session.starting_balance) if session.starting_balance else None, "ending_balance": float(session.ending_balance) if session.ending_balance else None, "peak_balance": float(session.peak_balance) if session.peak_balance else None } except Exception as e: logger.error(f"Error getting today's session: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/check-risk-limits") async def check_risk_limits( order_value: float = Query(..., description="μ£Όλ¬Έ κΈˆμ•‘"), account_balance: float = Query(..., description="κ³„μ’Œ μž”κ³ "), db: AsyncSession = Depends(get_db) ): """ ⚠️ μ£Όλ¬Έ μ „ 리슀크 μ œν•œ 확인 - ν¬μ§€μ…˜ μ‚¬μ΄μ¦ˆ μ œν•œ - 일일 손싀 ν•œλ„ - 연속 손싀 μ œν•œ """ try: # 1. 였늘 μ„Έμ…˜ 확인 today = date.today() result = await db.execute( select(TradingSession) .where(TradingSession.session_date == today) ) session = result.scalar_one_or_none() if not session: session = TradingSession(session_date=today, starting_balance=Decimal(str(account_balance))) db.add(session) await db.commit() await db.refresh(session) # 2. 거래 쀑지 μƒνƒœ 확인 if session.is_paused: return { "allowed": False, "reason": session.pause_reason or "κ±°λž˜κ°€ μ€‘μ§€λ˜μ—ˆμŠ΅λ‹ˆλ‹€", "risk_level": "BLOCKED" } # 3. 일일 손싀 ν•œλ„ 확인 daily_loss_ratio = abs(float(session.daily_profit_loss)) / account_balance if account_balance > 0 else 0 if session.daily_profit_loss < 0 and daily_loss_ratio > MAX_DAILY_LOSS_RATIO: # μžλ™ 쀑지 session.is_paused = True session.pause_reason = f"일일 손싀 ν•œλ„ 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)" await db.commit() return { "allowed": False, "reason": session.pause_reason, "risk_level": "CRITICAL", "daily_loss": float(session.daily_profit_loss), "daily_loss_ratio": round(daily_loss_ratio * 100, 2) } # 4. 연속 손싀 μ œν•œ 확인 if session.consecutive_losses >= MAX_CONSECUTIVE_LOSSES: session.is_paused = True session.pause_reason = f"연속 {session.consecutive_losses}회 손싀 λ°œμƒ" await db.commit() return { "allowed": False, "reason": session.pause_reason, "risk_level": "CRITICAL", "consecutive_losses": session.consecutive_losses } # 5. ν¬μ§€μ…˜ μ‚¬μ΄μ¦ˆ μ œν•œ 확인 position_ratio = order_value / account_balance if account_balance > 0 else 0 if position_ratio > MAX_POSITION_SIZE_RATIO: return { "allowed": False, "reason": f"ν¬μ§€μ…˜ μ‚¬μ΄μ¦ˆ 초과 ({position_ratio*100:.1f}% > {MAX_POSITION_SIZE_RATIO*100}%)", "risk_level": "HIGH", "position_ratio": round(position_ratio * 100, 2), "max_allowed": round(MAX_POSITION_SIZE_RATIO * 100, 2) } # λͺ¨λ“  μ œν•œ 톡과 warnings = [] if session.consecutive_losses >= 2: warnings.append(f"연속 {session.consecutive_losses}회 손싀 쀑 (주의)") if daily_loss_ratio > MAX_DAILY_LOSS_RATIO * 0.5: warnings.append(f"일일 손싀 {daily_loss_ratio*100:.1f}% (주의)") return { "allowed": True, "risk_level": "WARNING" if warnings else "OK", "warnings": warnings, "position_ratio": round(position_ratio * 100, 2), "daily_loss_ratio": round(daily_loss_ratio * 100, 2), "consecutive_losses": session.consecutive_losses } except Exception as e: logger.error(f"Error checking risk limits: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/update-session") async def update_trading_session( trade_result: str = Query(..., description="win λ˜λŠ” loss"), profit_loss: float = Query(..., description="거래 손읡"), db: AsyncSession = Depends(get_db) ): """ πŸ”„ 거래 결과에 따라 μ„Έμ…˜ μ—…λ°μ΄νŠΈ - 연속 손싀/승리 μΉ΄μš΄ν„° - 일일 손읡 λˆ„μ  """ try: today = date.today() result = await db.execute( select(TradingSession) .where(TradingSession.session_date == today) ) session = result.scalar_one_or_none() if not session: session = TradingSession(session_date=today) db.add(session) # 손읡 λˆ„μ  current_pl = float(session.daily_profit_loss) if session.daily_profit_loss else 0 session.daily_profit_loss = Decimal(str(current_pl + profit_loss)) session.daily_trades_count += 1 # 연속 손싀/승리 μ—…λ°μ΄νŠΈ if trade_result == "loss": session.consecutive_losses += 1 session.consecutive_wins = 0 elif trade_result == "win": session.consecutive_wins += 1 session.consecutive_losses = 0 await db.commit() await db.refresh(session) return { "session_date": session.session_date.isoformat(), "consecutive_losses": session.consecutive_losses, "consecutive_wins": session.consecutive_wins, "daily_profit_loss": float(session.daily_profit_loss), "daily_trades_count": session.daily_trades_count, "is_paused": session.is_paused } except Exception as e: logger.error(f"Error updating session: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/win-rate") async def get_win_rate( days: int = Query(30, description="뢄석 κΈ°κ°„ (일)"), db: AsyncSession = Depends(get_db) ): """ πŸ“Š 승λ₯  및 손읡 톡계 """ try: since_date = datetime.utcnow() - timedelta(days=days) result = await db.execute( select(TradeHistory) .where(TradeHistory.created_at >= since_date) .where(TradeHistory.order_type == "SELL") .where(TradeHistory.status.in_(["EXECUTED", "FILLED"])) ) trades = result.scalars().all() if not trades: return { "win_rate": 0, "total_trades": 0, "message": "거래 내역이 μ—†μŠ΅λ‹ˆλ‹€" } wins = 0 losses = 0 total_profit = 0 total_loss = 0 for trade in trades: pl = float(trade.net_profit_loss) if trade.net_profit_loss else 0 if pl > 0: wins += 1 total_profit += pl elif pl < 0: losses += 1 total_loss += abs(pl) total_trades = wins + losses win_rate = (wins / total_trades * 100) if total_trades > 0 else 0 # Profit Factor (총 이읡 / 총 손싀) profit_factor = (total_profit / total_loss) if total_loss > 0 else 0 # 평균 승리/손싀 avg_win = total_profit / wins if wins > 0 else 0 avg_loss = total_loss / losses if losses > 0 else 0 # 손읡비 risk_reward = avg_win / avg_loss if avg_loss > 0 else 0 return { "win_rate": round(win_rate, 2), "total_trades": total_trades, "wins": wins, "losses": losses, "total_profit": round(total_profit, 2), "total_loss": round(total_loss, 2), "net_profit": round(total_profit - total_loss, 2), "profit_factor": round(profit_factor, 2), "avg_win": round(avg_win, 2), "avg_loss": round(avg_loss, 2), "risk_reward_ratio": round(risk_reward, 2) } except Exception as e: logger.error(f"Error calculating win rate: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/mdd/reset") async def reset_mdd(): """MDD μƒνƒœ μˆ˜λ™ μ΄ˆκΈ°ν™” (μ‹ κ·œ μ§„μž… 차단 ν•΄μ œ)""" from app.services.mdd_manager import mdd_manager before = { "is_paused": mdd_manager.is_paused, "peak_balance": mdd_manager.peak_balance, "reduction_applied": mdd_manager.reduction_applied, } mdd_manager.reset() logger.info("βœ… MDD μˆ˜λ™ μ΄ˆκΈ°ν™” μ™„λ£Œ") return { "message": "MDD μ΄ˆκΈ°ν™” μ™„λ£Œ β€” μ‹ κ·œ μ§„μž… κ°€λŠ₯", "before": before, "after": { "is_paused": mdd_manager.is_paused, "peak_balance": mdd_manager.peak_balance, "reduction_applied": mdd_manager.reduction_applied, }, }