## 재기동 내구성 - 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>
544 lines
20 KiB
Python
544 lines
20 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, 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,
|
||
},
|
||
}
|