Files
auto-stock/backend/app/api/endpoints/risk_management.py
shuim a45007cbb2 feat: TradingView 스타일 대시보드 + 차트 오버레이 API, Crisis 손절 소급 차단 플래그(기본 OFF)
- frontend: 대시보드를 TradingView 레이아웃으로 재구성(components/tv/*) — 캔들+MA/BB/거래량,
  평단·손절·목표·트레일링 가격선, 체결 마커, RSI 기준선은 실효 설정값, 하단 탭(보유·체결·DT 카드·
  비용 후 손익곡선/낙폭·저널), 디자인 토큰·한국식 색·최소 11px, 모바일 가로 스크롤 제거
- backend(GET 전용, require_api_key): /positions/overlay, /risk/equity-history,
  /strategy-config/display, /trades 응답 필드(side·strategy_type·commission·net_profit_loss),
  /risk/mdd-status thresholds, /market-data/chart 1m|5m(캐시·in-flight 공유)
- mean_reversion_strategy: RSI 청산 임계를 resolve_swing_exit_rsi_v2 로 추출(동작 동일) —
  청산과 화면 표시가 같은 값을 쓴다
- §8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY(기본 False) — 반사실 재생에서 Crisis 조기 손절이
  오히려 손실을 줄여 기본 OFF 유지, 정식 백테스트 전 켜지 말 것
- 유닛 스위트 2816 passed / 0 failed, tsc·build 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:47:00 +09:00

637 lines
27 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 _fill_or_created_at_v2():
"""체결 시각 표현 — executed_at 이 NULL 인 DT·스윙 청산·MDD 행은 created_at 으로 대체.
trade_executor._fill_or_created_at_v2 와 동일 정의(무거운 import 회피를 위해 로컬 정의).
"""
return func.coalesce(TradeHistory.executed_at, TradeHistory.created_at)
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
# 한도는 settings 우선(config.py 에 키가 생기면 자동 반영), 없으면 기존 값 유지 — 값 변경 없음.
MAX_DAILY_LOSS_RATIO = float(getattr(settings, "RISK_MAX_DAILY_LOSS_RATIO", 0.03)) # 일일 3% 손실 시 중단
MAX_CONSECUTIVE_LOSSES = int(getattr(settings, "RISK_MAX_CONSECUTIVE_LOSSES", 3)) # 3연속 손실 시 중단
_DT_STRATEGIES = ("daytrading", "day_trade", "dt")
COMMISSION_RATE = 0.00015 # 수수료 0.015%
TRADE_TAX_RATE = 0.0023 # 거래세 0.23% (매도 시만)
TOTAL_COST_RATE = COMMISSION_RATE * 2 + TRADE_TAX_RATE # 왕복 약 0.26%
def _resolve_account_balance(caller_balance: float):
"""호출자 잔고와 서버 잔고(mdd_manager 최근 갱신값, 없으면 peak 미사용)를 대조한다.
서버값이 있으면 min(호출자, 서버) — 큰 값 주입으로 한도를 희석하는 우회를 막는 보수적 선택.
서버값이 없으면 호출자 값을 쓰되 note 로 미검증을 표시한다.
"""
from app.services.mdd_manager import mdd_manager
try:
server = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0)
except (TypeError, ValueError):
server = 0.0
caller = float(caller_balance)
if server > 0:
if caller > server:
logger.warning(
f"[check-risk-limits] 호출자 잔고 {caller:,.0f} > 서버 잔고 {server:,.0f} — 서버값 사용"
)
return server, "server_balance_used"
return caller, "ok"
return caller, "server_balance_unverified"
async def _server_side_block_reason_v2(strategy: str, account_balance: float) -> Optional[str]:
"""TradingSession 행(기록 주체 없음)과 무관하게 서버 인메모리 상태로 신규 주문 차단 사유를 낸다.
- mdd_manager.can_open_new_position(): MDD -10% 정지 / 갱신 블라인드 fail-safe
- DT 전략: day_trading_state.daily_pnl 이 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT) 이하
조회 예외는 fail-closed(차단)로 처리한다.
"""
from app.services.mdd_manager import mdd_manager
try:
if not await mdd_manager.can_open_new_position():
return "MDD 정지 또는 MDD 갱신 불가(fail-safe) — 신규 진입 차단"
if (strategy or "").strip().lower() in ("daytrading", "day_trade", "dt"):
from app.services.daytrading.position_manager import day_trading_state
from app.services.day_trading_strategy import is_daily_loss_exceeded
pnl = float(day_trading_state.daily_pnl or 0.0)
assets = float(day_trading_state.last_total_assets or 0.0) or float(account_balance)
if is_daily_loss_exceeded(assets, pnl):
return (f"DT 일일 손실한도 도달 (pnl {pnl:,.0f}, "
f"한도 {float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:+.1f}%)")
except Exception as e:
logger.warning(f"[check-risk-limits] 서버 상태 조회 실패 — fail-closed: {e}")
return "서버 리스크 상태 조회 실패 — 신규 진입 차단(fail-closed)"
return None
_STREAK_EXCLUDED_STRATEGIES = ("manual", "mdd_reduction", "external")
async def _swing_realized_today_v2(db, stock_code: Optional[str] = None) -> tuple:
"""TradingSession 기록 주체가 없으므로 TradeHistory 에서 스윙 당일 실현손익·연속 손실을 직접 계산한다.
반환: (당일 순손익 합, 최근 연속 손실 횟수). SELECT 전용. 조회 실패 시 예외를 그대로 올린다
(호출부가 fail-closed 처리). DT 행은 제외한다.
연패는 §5.1 의미(동일 종목)에 맞춰 stock_code 가 주어질 때만 해당 종목으로 집계하고,
manual/mdd_reduction/external 청산은 제외한다. stock_code 가 없으면 0
(종목 무관 전역 연패는 §5.1 정의가 아니므로 loss_tracker 에 맡긴다).
"""
start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0)
res = await db.execute(
select(TradeHistory.net_profit_loss, TradeHistory.stock_code, TradeHistory.strategy_type)
.where(TradeHistory.order_type == "SELL")
.where(TradeHistory.status.in_(["EXECUTED", "COMPLETED", "FILLED"]))
.where(TradeHistory.created_at >= start)
.where(TradeHistory.net_profit_loss.isnot(None))
.where(TradeHistory.strategy_type.notin_(["daytrading", "day_trade"]))
.order_by(desc(TradeHistory.created_at))
)
rows = res.all()
total = sum(float(r[0]) for r in rows)
streak = 0
if stock_code:
for pl, code, st in rows:
if code != stock_code or st in _STREAK_EXCLUDED_STRATEGIES:
continue
if float(pl) < 0:
streak += 1
else:
break
return total, streak
def _mdd_thresholds_v2() -> dict:
from app.api.endpoints.tv_overlay import mdd_thresholds
return mdd_thresholds()
def _mdd_state_v2() -> dict:
"""실제 MDD 게이트(mdd_manager) 상태 스냅샷. 읽기 전용, DB 접근 없음.
drawdown_pct 는 (peak - 최근 관측 잔고) / peak * 100 (양수 = 하락폭).
peak 또는 최근 잔고를 모르면 None (0% '안전' 으로 오표시하지 않는다).
"""
from app.services.mdd_manager import mdd_manager
peak = float(getattr(mdd_manager, "peak_balance", 0.0) or 0.0)
last = float(getattr(mdd_manager, "_last_balance", 0.0) or 0.0)
dd_pct = round((peak - last) / peak * 100, 2) if peak > 0 and last > 0 else None
return {
"peak_balance": peak if peak > 0 else None,
"last_balance": last if last > 0 else None,
"drawdown_pct": dd_pct,
"is_paused": bool(mdd_manager.is_paused),
"is_short_term_strategy_paused": bool(getattr(mdd_manager, "is_short_term_strategy_paused", False)),
"reduction_applied": mdd_manager.reduction_applied,
"update_health": mdd_manager.get_update_health(),
# TV 재구성 계약(Optional): settings 의 MDD 단계 임계(%, 음수)
"thresholds": _mdd_thresholds_v2(),
}
@router.get("/mdd-status")
async def get_mdd_status_v2():
"""실제 MDD 게이트(-10%/-20%) 상태. /risk-metrics 의 손익곡선과 정의가 다르다."""
return _mdd_state_v2()
@router.get("/risk-metrics")
async def get_risk_metrics(
days: int = Query(30, description="분석 기간 (일)"),
db: AsyncSession = Depends(get_db)
):
"""
📊 리스크 관리 메트릭 조회
- 실현손익 누적곡선의 최대 낙폭(원, 참고용). 실제 MDD 는 mdd_status / /mdd-status
- 누적 손익 곡선
- 현재 계좌 상태
"""
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(_fill_or_created_at_v2(), TradeHistory.id)
)
trades = result.scalars().all()
if not trades:
return {
"max_drawdown": 0,
"max_drawdown_pct": None,
"mdd_status": _mdd_state_v2(),
"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
# 참고용 실현손익 곡선 — 낙폭 % 는 peak 가 손익 누적값이라 의미가 없어 판정하지 않는다.
return {
"max_drawdown": round(float(max_drawdown), 2),
"max_drawdown_pct": None,
"current_equity": round(cumulative_pl, 2),
"peak_equity": round(peak, 2),
"trades_count": len(trades),
"risk_level": None,
"note": "실현손익 누적곡선(참고용). 리스크 수준 판단은 mdd_status 를 사용",
"mdd_status": _mdd_state_v2(),
"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))
.order_by(_fill_or_created_at_v2(), TradeHistory.id)
)
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()
_mdd = _mdd_state_v2()
if not session:
# [하드닝 R4] GET 은 DB 를 쓰지 않는다(§3.2: 쓰기는 db_task_queue 경유, 무인증 GET 부하 방지).
# 세션 행을 쓰는 엔드포인트는 없다(R1 에서 /update-session 제거).
return {
"session_date": today.isoformat(),
"consecutive_losses": 0,
"consecutive_wins": 0,
"daily_profit_loss": 0,
"daily_trades_count": 0,
"is_paused": _mdd["is_paused"],
"pause_reason": "MDD 정지" if _mdd["is_paused"] else None,
"mdd_status": _mdd,
"starting_balance": None,
"ending_balance": None,
"peak_balance": None,
}
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": bool(session.is_paused) or _mdd["is_paused"],
"pause_reason": session.pause_reason or ("MDD 정지" if _mdd["is_paused"] else None),
"mdd_status": _mdd,
"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 (종목당 한도 분기)"),
stock_code: Optional[str] = Query(None, description="종목 단위 연패 집계용(없으면 연패 가드 생략)"),
db: AsyncSession = Depends(get_db)
):
"""
⚠️ 주문 전 리스크 제한 확인
[미사용 표기] 2026-10 기준 코드베이스(py/ts/mcp-server)에 호출자가 없다. 실제 진입 가드는
trade_executor/entry_service/buy_guards 에 있으며 이 엔드포인트 보강은 매매 안전에 기여하지 않는다.
- 포지션 사이즈 제한 (전략별: 스윙 MAX_POSITION_SIZE / DT DAY_TRADE_MAX_CAPITAL_RATIO)
- 일일 손실 한도
- 연속 손실 제한
[2026-09-28] `strategy` 는 기본값이 있는 추가 파라미터다(기존 호출부 동작 불변).
이전에는 DT 주문(설계상 6%)을 스윙 상한 4%로 검사해 정상 주문을 위반으로 냈다.
"""
# [하드닝 R4] 호출자 제공 잔고 신뢰 제거 — 서버 잔고(mdd_manager)가 있으면 더 작은 쪽 사용.
# (가드는 try 밖) 큰 값을 넘겨 손실비율·포지션비율을 희석하는 우회를 막는다.
account_balance, _bal_note = _resolve_account_balance(account_balance)
# [하드닝 R1] 서버측 실상태 가드(try 밖): MDD 정지/블라인드, DT 일일 손실한도.
_block = await _server_side_block_reason_v2(strategy, account_balance)
if _block:
return {"allowed": False, "reason": _block, "risk_level": "BLOCKED"}
# [하드닝 R3] 스윙 일일손실·연패는 TradingSession(기록 주체 없음) 대신 TradeHistory 실계산으로 가드(try 밖, fail-closed).
_sw_pnl, _sw_streak = 0.0, 0
if (strategy or "swing").strip().lower() not in _DT_STRATEGIES:
try:
_sw_pnl, _sw_streak = await _swing_realized_today_v2(db, stock_code=stock_code)
except Exception as e:
logger.warning(f"[check-risk-limits] 스윙 당일 손익 조회 실패 — fail-closed: {e}")
return {"allowed": False, "reason": "당일 손익 조회 실패 — 신규 진입 차단(fail-closed)",
"risk_level": "BLOCKED"}
if _sw_pnl < 0 and account_balance > 0 and abs(_sw_pnl) / account_balance > MAX_DAILY_LOSS_RATIO:
_r = abs(_sw_pnl) / account_balance
return {"allowed": False,
"reason": f"일일 손실 한도 도달 ({_r*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)",
"risk_level": "CRITICAL", "daily_loss": _sw_pnl,
"daily_loss_ratio": round(_r * 100, 2)}
if _sw_streak >= MAX_CONSECUTIVE_LOSSES:
return {"allowed": False, "reason": f"연속 {_sw_streak}회 손실 발생",
"risk_level": "CRITICAL", "consecutive_losses": _sw_streak}
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:
# [하드닝 R4] 비영속 기본값 — 호출자 제공 잔고를 하루 기준으로 영속하지 않고,
# 직접 INSERT/commit(동시 호출 시 session_date 중복 경합)도 하지 않는다.
session = TradingSession(
session_date=today,
consecutive_losses=0,
consecutive_wins=0,
daily_profit_loss=0,
daily_trades_count=0,
is_paused=False
)
# 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:
# 자동 중지
# is_paused 영속은 하지 않는다(직접 commit 제거). 손익·연패는 세션에 남아 있어
# 매 호출마다 같은 판정이 재현된다.
_reason = f"일일 손실 한도 도달 ({daily_loss_ratio*100:.1f}% > {MAX_DAILY_LOSS_RATIO*100}%)"
return {
"allowed": False,
"reason": _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:
return {
"allowed": False,
"reason": f"연속 {session.consecutive_losses}회 손실 발생",
"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,
"balance_note": _bal_note,
}
except Exception as e:
logger.error(f"Error checking risk limits: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# [하드닝 R1] POST /update-session 제거: 직접 commit(§3.2 위반)·입력 무검증이었고 호출부가 0건이라
# 세션 행이 생기지 않아 check-risk-limits 의 일일손실/연패 가드가 항상 통과했다.
# 서버측 실상태(mdd_manager, DT daily_pnl)를 check-risk-limits 가 직접 읽는다.
@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 초기화 완료 — peak 를 현재 잔고로 재설정(이후 다시 -10% 하락 시 축소 재발동), 신규 진입 가능",
"before": before,
"after": {
"is_paused": mdd_manager.is_paused,
"peak_balance": mdd_manager.peak_balance,
"reduction_applied": mdd_manager.reduction_applied,
},
}