Files
auto-stock/backend/app/api/endpoints/risk_management.py
2026-02-23 16:01:17 +09:00

496 lines
17 KiB
Python

"""
🔥 리스크 관리 및 성과 평가 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,
},
}