diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py index 7632b11..3f37115 100644 --- a/backend/app/api/endpoints/day_trading.py +++ b/backend/app/api/endpoints/day_trading.py @@ -48,11 +48,16 @@ from app.core.database import get_db, AsyncSessionLocal from app.models.models import TradeHistory, ActiveTrade from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update as sa_update +# MCP risk assessment — 모듈 최상단에서 import (순환 참조 시 별도 util 모듈 분리 고려) +from app.api.endpoints.mcp import request_mcp_risk_assessment router = APIRouter() logger = logging.getLogger(__name__) KST = pytz.timezone("Asia/Seoul") +# API 호출 간격 (settings.API_MIN_REQUEST_INTERVAL 사용, 기본 0.5초) +_API_CALL_DELAY: float = settings.API_MIN_REQUEST_INTERVAL + # ── 인메모리 상태 ────────────────────────────────────────────────────────────── @@ -93,7 +98,7 @@ _closed_today: set = set() # 마지막 초기화 날짜 _last_reset_date: Optional[date] = None -# 매매 실행 lock +# 매매 실행 lock — _positions/_daily_pnl/_closed_today 변경 시 반드시 획득 _trade_lock = asyncio.Lock() @@ -300,6 +305,10 @@ async def run_day_trading_cycle() -> Dict[str, Any]: global _last_total_assets if total_assets > 0: _last_total_assets = total_assets + else: + # 총자산 조회 실패 시 신규 진입 차단 — 손실 한도 체크 우회 방지 + logger.warning("⚠️ 총자산 조회 실패(0원) → 신규 진입 차단") + return result if not is_daily_loss_exceeded(total_assets, _daily_pnl): current_day_trade_amount = sum( @@ -355,7 +364,7 @@ async def _collect_opening_range() -> None: ) except Exception as e: logger.warning(f"ORB 수집 실패 [{code}]: {e}") - await asyncio.sleep(0.6) + await asyncio.sleep(_API_CALL_DELAY) async def _check_exits( @@ -442,7 +451,7 @@ async def _check_exits( })) except Exception as e: logger.error(f"청산 체크 오류 [{code}]: {e}") - await asyncio.sleep(0.6) + await asyncio.sleep(_API_CALL_DELAY) return exits @@ -511,7 +520,7 @@ async def _scan_signals( except Exception as e: logger.warning(f"신호 스캔 오류 [{code}]: {e}") - await asyncio.sleep(0.6) + await asyncio.sleep(_API_CALL_DELAY) # confidence 내림차순 정렬 results.sort(key=lambda x: x["signal"].confidence, reverse=True) @@ -524,7 +533,6 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict 주문 직전 MCP RISK_ASSESSMENT를 호출해 approve 시에만 매수한다 (이슈 4). MCP 장애/타임아웃은 fail-open(approve=True)으로 처리해 매매를 차단하지 않는다. """ - global _trade_lock async with _trade_lock: if len(_positions) >= MAX_POSITIONS: return None @@ -535,7 +543,6 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict return None # ── MCP RISK_ASSESSMENT (공통 함수, 이슈 4 — day_trading 측) ──────────── - from app.api.endpoints.mcp import request_mcp_risk_assessment _mcp_trade_plan = { "action": "BUY", "strategy": signal.strategy, @@ -666,7 +673,7 @@ async def _execute_eod_flat() -> None: })) except Exception as e: logger.error(f"EOD 청산 오류 [{code}]: {e}") - await asyncio.sleep(0.6) + await asyncio.sleep(_API_CALL_DELAY) async def restore_positions_from_db() -> None: @@ -805,12 +812,20 @@ async def execute_day_trade(req: ExecuteRequest): total_assets = portfolio["total_assets"] available_cash = portfolio["available_cash"] + # 총자산 조회 실패 시 진입 차단 — 손실 한도 체크 우회 방지 + if total_assets <= 0: + raise HTTPException(503, "총자산 조회에 실패했습니다. 잠시 후 다시 시도해주세요.") + if is_daily_loss_exceeded(total_assets, _daily_pnl): raise HTTPException(400, "일일 손실 한도(-3%)에 도달하여 신규 진입이 차단됩니다.") candles_1m = await _fetch_candles(code) + # 분봉 데이터 없으면 신호 분석 불가 → early return + if not candles_1m: + return {"status": "no_signal", "message": "분봉 데이터를 가져올 수 없습니다."} + candles_5m = aggregate_to_5min(candles_1m) - current_price = candles_1m[-1].close if candles_1m else 0 + current_price = candles_1m[-1].close vwap = calculate_vwap(candles_1m) orb = _orb_cache.get(code)