496 lines
17 KiB
Python
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,
|
|
},
|
|
}
|