- 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>
637 lines
27 KiB
Python
637 lines
27 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 _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,
|
||
},
|
||
}
|