""" πŸ”₯ 리슀크 관리 및 μ„±κ³Ό 평가 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, }, }