fix: day_trading 코드 리뷰 사항 전체 반영

- _execute_eod_flat에 _trade_lock 적용 (race condition 수정)
- total_assets=0일 때 신규 진입 차단 (손실한도 체크 우회 방지)
- MCP import를 모듈 최상단으로 이동
- API_CALL_DELAY 상수화 (settings.API_MIN_REQUEST_INTERVAL 사용)
- _execute_entry의 불필요한 global _trade_lock 제거
- candles_1m 빈 리스트일 때 execute 엔드포인트 early return 추가
This commit is contained in:
2026-02-27 09:07:56 +09:00
parent 8c8de61d67
commit c29e7f9716

View File

@@ -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)