Files
auto-stock/backend/app/api/endpoints/risk_management.py
shuim 7abbb3411c fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류
## 재기동 내구성
- scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그
  (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다)
- 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화
  날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합
  (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다)
- db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은
  DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다)
- 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단
- swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning

## DT 진입 복구
- 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어
  5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전
  발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리
  (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경)
- 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽)
  임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐
- 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아
  개장 직후 게이트가 자동 통과하는 것을 방지
- 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그
- watchlist 30분 갱신 시 ORB 보유 종목 우선 유지

## 유입 잔고 격리
- sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로
  편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리
  (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요)
- 격리분 손절선 이탈은 집행 없이 warning + summary 노출
- 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운

## 4% 상한
- market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프
- 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증)

## 프론트
- 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시
- cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제

테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음)
전략 임계값 변경 0건

⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어
   미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후
   "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:25 +09:00

544 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
🔥 리스크 관리 및 성과 평가 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, timezone, timedelta, date
from decimal import Decimal
from typing import List, Optional
import logging
import numpy as np
from app.core.database import get_db
_KST = timezone(timedelta(hours=9))
def _today_kst() -> date:
"""KST 기준 오늘 날짜 반환 (UTC 기준 date.today() 대신 항상 이 함수 사용)"""
return datetime.now(_KST).date()
from app.core.config import settings
from app.core.security import require_api_key
from app.models.models import TradeHistory, TradingSession, Portfolio
from app.core.timeutil import now_kst
router = APIRouter()
logger = logging.getLogger(__name__)
# 🔥 리스크 관리 설정 — config.py 참조
MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.04 (4%) — 스윙 절대 상한
# 데이트레이딩은 설계상 종목당 6%(DAY_TRADE_MAX_CAPITAL_RATIO, 5종목×6%=30%)이므로
# 스윙 상한(4%)으로 검사하면 정상 DT 주문이 '위반'으로 오판정된다 → 전략별로 분기한다.
DT_POSITION_SIZE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO
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 = now_kst() - 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", "COMPLETED", "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 = now_kst() - 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", "COMPLETED", "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 계산 — 거래 빈도 기반 연간화
# 거래가 일 1회가 아닌 경우 sqrt(252)로 연간화하면 부풀려짐.
# 실제 거래 기간(calendar days) 대비 거래 횟수로 연간 거래 빈도를 추정한다.
# trades_per_year: 관측 기간 내 거래 빈도를 252 거래일 기준으로 환산
first_trade_date = trades[0].executed_at or trades[0].created_at
last_trade_date = trades[-1].executed_at or trades[-1].created_at
span_days = max((last_trade_date - first_trade_date).days, 1)
trades_per_year = len(returns) / span_days * 252
# 무위험 수익률은 0으로 가정
sharpe = (avg_return * np.sqrt(trades_per_year)) / 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 * trades_per_year * 100, 2), # %
"trades_per_year": round(trades_per_year, 1),
"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 = _today_kst()
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", dependencies=[Depends(require_api_key)])
async def check_risk_limits(
order_value: float = Query(..., description="주문 금액"),
account_balance: float = Query(..., description="계좌 잔고"),
strategy: str = Query("swing", description="전략 구분: swing | daytrading (종목당 한도 분기)"),
db: AsyncSession = Depends(get_db)
):
"""
⚠️ 주문 전 리스크 제한 확인
- 포지션 사이즈 제한 (전략별: 스윙 MAX_POSITION_SIZE / DT DAY_TRADE_MAX_CAPITAL_RATIO)
- 일일 손실 한도
- 연속 손실 제한
[2026-09-28] `strategy` 는 기본값이 있는 추가 파라미터다(기존 호출부 동작 불변).
이전에는 DT 주문(설계상 6%)을 스윙 상한 4%로 검사해 정상 주문을 위반으로 냈다.
"""
try:
# 1. 오늘 세션 확인
today = _today_kst()
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)),
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)
# 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. 포지션 사이즈 제한 확인 (전략별 한도)
_strategy = (strategy or "swing").strip().lower()
_position_cap = (
DT_POSITION_SIZE_RATIO
if _strategy in ("daytrading", "day_trade", "dt")
else MAX_POSITION_SIZE_RATIO
)
position_ratio = order_value / account_balance if account_balance > 0 else 0
if position_ratio > _position_cap:
return {
"allowed": False,
"reason": f"포지션 사이즈 초과 ({position_ratio*100:.1f}% > {_position_cap*100}%)",
"risk_level": "HIGH",
"strategy": _strategy,
"position_ratio": round(position_ratio * 100, 2),
"max_allowed": round(_position_cap * 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", dependencies=[Depends(require_api_key)])
async def update_trading_session(
trade_result: str = Query(..., description="win 또는 loss"),
profit_loss: float = Query(..., description="거래 손익"),
db: AsyncSession = Depends(get_db)
):
"""
🔄 거래 결과에 따라 세션 업데이트
- 연속 손실/승리 카운터
- 일일 손익 누적
"""
try:
today = _today_kst()
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)
# 손익 누적
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 = now_kst() - 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", "COMPLETED", "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", dependencies=[Depends(require_api_key)])
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,
}
await 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,
},
}