"""데이트레이딩 진입 서비스 ORB / VWAP / RSI+BB 신호 기반 당일 매매 진입 처리. day_trading_strategy.py의 신호 함수를 재활용하고 인메모리 상태(DayTradingState)를 업데이트. 진입 기준 (CLAUDE.md 섹션 7.3): - 감시 종목 대상 (watchlist) - ORB: 개장 범위 돌파 신호 (09:30 이후) - VWAP: VWAP 반등 신호 - RSI+BB: 5분봉 RSI < 30, BB 하단 근처 - 포지션 최대 3종목, 종목당 총자본 4% (CLAUDE.md 5.1) - MDD -10% 이하 시 비활성화 - 14:30 이후 신규 진입 마감 (CLAUDE.md 7.3) - 점심시간(11:30~13:00) VWAP/RSI_BB 진입 제한 진입 흐름: 1. MDD / 적응형 전략 / 플레이북 / 일일 손실 / 쿨다운 체크 2. 시장 국면 체크 (SIDEWAYS 차단) 3. 시간대 체크 (14:30 이후 마감) 4. 포지션 한도 체크 (최대 3종목) 5. 감시 종목 순회 → 5분봉 데이터 조회 → 신호 체크 6. 포지션 사이징 (confidence 기반 동적 조정) 7. MCP 리스크 평가 (선택적) 8. KIS API 매수 주문 실행 9. 인메모리 상태 + DB 큐 기록 아키텍처 규칙: - DB 직접 접근 금지 (CLAUDE.md 3.1) - 체결 기록은 db_worker 큐 경유 - 매수 주문은 safe_place_order() 사용 (세마포어+분산락 보호) """ import asyncio import logging from datetime import datetime, time as dtime import pytz from app.core.config import settings from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( parse_minute_candles, aggregate_to_5min, calculate_vwap, calculate_opening_range, check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal, calculate_day_trade_position_size, is_daily_loss_exceeded, MAX_POSITIONS, ) from app.services.daytrading.position_manager import day_trading_state from app.services.db_worker import enqueue_trade_history, enqueue_active_trade_upsert from app.services.mdd_manager import mdd_manager from app.services.market_analyzer import check_market_regime from app.services.loss_tracker import loss_tracker from app.core.market_schedule import is_opening_range_period, is_day_trade_entry_window from app.services.market_playbook import ( is_daytrading_allowed_by_playbook, is_vi_active, ) logger = logging.getLogger(__name__) # 한국 표준시(KST) 타임존 — 장 시간/시간대 체크에 사용 _KST = pytz.timezone("Asia/Seoul") # 감시 종목 딕셔너리 (code → name) # day_trading.py의 _watchlist와 동기화되며, POST /api/daytrading/watchlist 또는 # populate_daytrading_watchlist()로 설정됨 _watchlist: dict[str, str] = {} # True = populate_daytrading_watchlist()로 자동 구성된 목록 (스케줄러가 30분마다 갱신 가능) # False = API로 수동 설정된 목록 (자동 갱신으로 덮어쓰지 않음) _watchlist_auto: bool = False def set_watchlist(stocks: list[dict]) -> None: """감시 종목 설정 (수동 — 이후 자동 갱신 대상에서 제외). Args: stocks: 종목 목록. 각 항목은 {"code": "005930", "name": "삼성전자"} 형태 """ global _watchlist, _watchlist_auto _watchlist = {s["code"]: s.get("name", s["code"]) for s in stocks} _watchlist_auto = False logger.info(f"[DayEntry] 감시 종목 설정: {len(_watchlist)}개") def is_watchlist_auto() -> bool: """현재 감시 목록이 자동 구성(populate)된 것인지 여부.""" return _watchlist_auto def get_watchlist() -> dict[str, str]: """현재 감시 종목 딕셔너리 복사본 반환. Returns: dict[str, str]: {종목코드: 종목명} 딕셔너리 """ return dict(_watchlist) async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20) -> int: """stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정. 배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을 자동으로 데이트레이딩 감시 대상으로 설정. 필터 조건: - 오늘 날짜 분석 결과만 사용 - score >= min_score (기본 1점 이상) - RSI 값이 존재 (기술적 분석 완료 종목만) - 현재가 > 5,000원 (CLAUDE.md 5.4 가격 필터) Args: min_score: 최소 점수 기준 (기본 1) max_stocks: 최대 감시 종목 수 (기본 20) Returns: int: 설정된 감시 종목 수 Note: 이 함수는 DB 읽기 전용으로, CLAUDE.md 3.1 예외 해당. populate는 스케줄러 시작 시 1회, 이후 30분마다 갱신. """ from app.core.database import AsyncSessionLocal from app.models.models import StockAnalysis from sqlalchemy import select, func today = datetime.now(_KST).date() async with AsyncSessionLocal() as session: # [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 → # ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근 # 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨. latest_date = ( await session.execute( select(func.max(StockAnalysis.analysis_date)).where( StockAnalysis.analysis_date <= today ) ) ).scalar_one_or_none() if latest_date is None: logger.info("[DayEntry] stock_analysis 데이터 없음") return 0 if latest_date != today: logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성") result = await session.execute( select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) .where( StockAnalysis.analysis_date == latest_date, StockAnalysis.score >= min_score, StockAnalysis.rsi.isnot(None), StockAnalysis.current_price > 5000, ) .order_by(StockAnalysis.score.desc()) .limit(max_stocks) ) rows = result.all() if not rows: logger.info(f"[DayEntry] stock_analysis({latest_date})에 조건 충족 종목 없음") return 0 stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows] set_watchlist(stocks) global _watchlist_auto _watchlist_auto = True logger.info(f"[DayEntry] 자동 watchlist 설정: {len(stocks)}개 (score>={min_score})") return len(stocks) async def collect_opening_ranges() -> None: """09:00~09:30 ORB(Opening Range Breakout) 데이터 수집. 감시 종목의 시초가 레인지(고가/저가)를 수집하여 ORB 돌파 전략의 기준값으로 사용. 호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출 KIS API 호출 간격: 0.3초 (레이트 리밋 준수) """ day_trading_state.reset_if_new_day() for stock_code in list(_watchlist.keys()): # 이미 ORB가 수집된 종목은 건너뜀 if day_trading_state.has_orb(stock_code): continue try: # 당일 분봉 전체 조회 → 시초가 레인지 계산 raw = await korea_investment_api.get_minute_chart_full_day(stock_code) candles = parse_minute_candles(raw) if candles: orb = calculate_opening_range(candles) if orb: day_trading_state.set_orb(stock_code, orb) logger.debug(f"[DayEntry] ORB 수집: {stock_code} {orb}") # KIS API 레이트 리밋 방지 (초당 약 3회) await asyncio.sleep(0.3) except Exception as e: logger.debug(f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}") async def scan_and_enter() -> list[dict]: """감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행. 09:30~14:30 구간에서 30초 주기로 호출 (14:30 이후 진입 마감). 진입 전 체크 순서 (P0 = 최우선, P1 = 중요): [P0] MDD -10% 체크 → 단기 전략 비활성화 [P0] 플레이북 체크 → FOMC/옵션만기 등 이벤트 당일 차단 [P0] 일일 손실 한도 -3% 체크 [P1] 3연패 쿨다운 체크 [P0] 시장 국면 체크 → SIDEWAYS/CRASH 등 차단 [P1] 14:30 이후 신규 진입 마감 Returns: list[dict]: 진입 실행 결과 목록. 각 항목: - stock_code, stock_name, quantity, price, strategy, reason """ day_trading_state.reset_if_new_day() entries = [] # MDD 체크 short_term_ok = await mdd_manager.is_short_term_strategy_enabled() if not short_term_ok: logger.info("[DayEntry] MDD 차단 — 단기 전략 비활성화") return entries # 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화 try: from app.services.adaptive_strategy import get_current_adaptive_params _adp_dt = get_current_adaptive_params() if not _adp_dt.fallback_used and not _adp_dt.daytrading_enabled: logger.info("[DayEntry] 적응형 전략 %s 모드 — 데이트레이딩 비활성화", _adp_dt.trading_mode.upper()) return entries except Exception: pass # adaptive 모듈 실패 시 기존 로직 유지 # [P0] 플레이북 체크 — FOMC/옵션만기/서킷브레이커 등 이벤트 당일 차단 if not is_daytrading_allowed_by_playbook(): logger.info("[DayEntry] ⛔ 플레이북 차단 — 데이트레이딩 비활성화 (이벤트 대응)") return entries # [P0] 일일 손실 한도 체크 — 청산 후 즉시 재진입 방지 if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl): logger.info("[DayEntry] ⛔ 일일 손실 한도(-3%) 초과 — 신규 진입 차단") return entries # [P1] 쿨다운 — 3연패 시 당일 진입 중단 if day_trading_state.is_cooldown_active(): logger.info("[DayEntry] ⛔ 3연패 쿨다운 — 신규 진입 차단") return entries # [P0] 시장 국면 체크 — CRASH/HALTED에서만 진입 차단 # 데이트레이딩은 분봉 기반이라 일봉 SIDEWAYS/strong_bearish와 무관하게 # 장중 변동성이 있으면 진입 가능 (CLAUDE.md 7.3 전략 독립성) _BLOCK_REGIMES_DT = {"crash", "halted"} try: regime_info = await check_market_regime() regime = regime_info.get("regime", "neutral") if regime in _BLOCK_REGIMES_DT: logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단") return entries logger.info(f"[DayEntry] 시장 국면: {regime}") except Exception as e: logger.info(f"[DayEntry] 시장 국면 조회 실패 (계속 진행): {e}") # [시간대 제한 완화 2026-09-22] 점심시간(11:30~13:00) VWAP/RSI_BB 제한 제거. # 신규 진입 마감만 유지 — settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN (기본 15:00, # EOD 15:20 강제청산 전 최소 20분 확보). 하드코딩 14:30 → 설정값. now_time = datetime.now(_KST).time() _is_lunch_break = False _cutoff = dtime(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN) if now_time >= _cutoff: logger.info(f"[DayEntry] ⛔ {_cutoff.strftime('%H:%M')} 이후 — 신규 진입 마감") return entries # 포지션 한도 체크 if day_trading_state.position_count() >= MAX_POSITIONS: logger.info(f"[DayEntry] ⛔ 포지션 한도 ({MAX_POSITIONS}) 도달") return entries # 포트폴리오 총자산 조회 (캐시 활용) try: balance = await korea_investment_api.get_balance() # output2가 빈 리스트([])로 오는 경우까지 방어 (get의 default는 키가 없을 때만 적용됨) summary = (balance.get("output2") or [{}])[0] total_assets = float(summary.get("tot_evlu_amt", 0) or 0) available_cash = float(summary.get("prvs_rcdl_excc_amt", 0) or 0) day_trading_state.last_total_assets = total_assets except Exception as e: logger.debug(f"[DayEntry] 잔고 조회 실패: {e}") total_assets = day_trading_state.last_total_assets available_cash = 0.0 if total_assets <= 0: return entries # [BUG FIX] 글로벌 자본 사용률 가드 — 전체 포트폴리오 투자액이 MAX_TOTAL_CAPITAL_USAGE 초과 시 진입 차단 total_invested = sum( pos.get("entry_price", 0) * pos.get("quantity", 0) for pos in day_trading_state.positions.values() ) # 스윙 포트폴리오 투자액도 합산 (잔고 조회에서 available_cash로 간접 계산) swing_invested = total_assets - available_cash - total_invested all_invested = max(0, total_invested + swing_invested) if total_assets > 0 and all_invested / total_assets > settings.MAX_TOTAL_CAPITAL_USAGE: logger.warning( f"[DayEntry] 전체 자본 사용률 초과: {all_invested/total_assets:.1%} > " f"{settings.MAX_TOTAL_CAPITAL_USAGE:.0%} — 신규 진입 차단" ) return entries scanned = 0 skipped_held = 0 skipped_closed = 0 no_signal = 0 for stock_code, stock_name in list(_watchlist.items()): # 이미 보유 중이거나 오늘 이미 청산한 종목 제외 if stock_code in day_trading_state.positions: skipped_held += 1 continue if day_trading_state.is_closed_today(stock_code): skipped_closed += 1 continue if day_trading_state.position_count() >= MAX_POSITIONS: break scanned += 1 try: entry = await _try_enter( stock_code, stock_name, total_assets, available_cash, is_lunch_break=_is_lunch_break, ) if entry: entries.append(entry) await asyncio.sleep(0.5) # KIS API 레이트 리밋 else: no_signal += 1 except Exception as e: logger.error(f"[DayEntry] 진입 시도 오류 ({stock_code}): {e}", exc_info=True) logger.info( f"[DayEntry] 스캔 완료: {scanned}종목 스캔, {len(entries)}진입, " f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료" ) return entries async def _try_enter( stock_code: str, stock_name: str, total_assets: float, available_cash: float, is_lunch_break: bool = False, ) -> dict | None: """단일 종목에 대해 진입 신호 체크 및 매수 주문 실행. 처리 흐름: 1. VI 쿨다운 체크 (변동성 완화장치 발동 후 10분간 차단) 2. 5분봉 데이터 조회 및 VWAP/ORB 계산 3. 신호 체크 (ORB → VWAP → RSI_BB 순서, 점심시간 VWAP/RSI_BB 제한) 4. confidence 기반 포지션 사이징 5. MCP 리스크 평가 (선택적, 실패 시 진행) 6. safe_place_order()로 시장가 매수 (세마포어+분산락 보호) 7. 인메모리 상태 업데이트 + DB 큐 기록 Args: stock_code: 종목 코드 stock_name: 종목명 total_assets: 총자산 (포지션 사이징 기준) available_cash: 가용 현금 is_lunch_break: 점심시간 여부 (11:30~13:00) Returns: dict | None: 진입 실행 결과 또는 None (신호 없음/실패) """ try: # loss_tracker ban 체크 — 3연패 종목 42일간 진입 차단 (CLAUDE.md 5.5) if await loss_tracker.is_banned_safe(stock_code): logger.debug(f"[DayEntry] loss_tracker ban — {stock_code} 진입 스킵") return None # VI 쿨다운 체크 — VI 발동 후 10분간 진입 차단 if is_vi_active(stock_code): logger.debug(f"[DayEntry] VI 쿨다운 — {stock_code} 진입 스킵") return None raw = await korea_investment_api.get_minute_chart_full_day(stock_code) candles = parse_minute_candles(raw) if not candles: return None candles_5m = aggregate_to_5min(candles) if len(candles_5m) < 5: return None vwap = calculate_vwap(candles) orb = day_trading_state.get_orb(stock_code) current_price = float(candles_5m[-1].close) if candles_5m else 0.0 if current_price <= 0: return None # 신호 체크 (ORB → VWAP → RSI+BB 순) signal = None if orb: signal = check_orb_signal(stock_code, stock_name, candles, orb, current_price) # [P1] 점심시간(11:30~13:00)에는 VWAP/RSI_BB 진입 제한 — 유동성 저하 if not is_lunch_break: if not signal and vwap: signal = check_vwap_bounce_signal(stock_code, stock_name, candles_5m, vwap, current_price) if not signal: signal = check_rsi_bb_signal(stock_code, stock_name, candles_5m, current_price) if not signal: return None # [P1] confidence 기반 포지션 사이징 — 신호 품질에 따라 예산 조정 current_day_trade_amount = sum( pos.get("entry_price", 0) * pos.get("quantity", 0) for pos in day_trading_state.positions.values() ) budget = calculate_day_trade_position_size( total_assets=total_assets, available_cash=available_cash, current_day_trade_amount=current_day_trade_amount, ) # confidence 스케일링: 기준 0.7 대비 비례 조정 (0.6→86%, 0.7→100%, 0.85→121%) confidence_scale = min(1.2, signal.confidence / 0.7) budget = budget * confidence_scale quantity = int(budget // current_price) if current_price > 0 else 0 if quantity <= 0: return None # MCP RISK_ASSESSMENT (선택적) try: from app.api.endpoints.mcp import request_mcp_risk_assessment trade_plan = { "action": "BUY", "strategy": "daytrading", "stock_code": stock_code, "price": current_price, "quantity": quantity, "signal": signal.strategy, } mcp_approved, mcp_reason = await request_mcp_risk_assessment( trade_plan, {"strategy": "daytrading"}, {"stock_code": stock_code} ) if not mcp_approved: logger.debug(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}") return None except Exception: pass # MCP 실패 시 진입 계속 # KIS API 매수 주문 (시장가) — 분산락+세마포어 보호 from app.services.trade_executor import safe_place_order order_resp = await safe_place_order( stock_code=stock_code, order_type="BUY", quantity=quantity, price=None, # 시장가 ) if not order_resp or order_resp.get("rt_cd") != "0": err = order_resp.get("msg1", "unknown") if order_resp else "no response" logger.error(f"[DayEntry] 매수 주문 실패 ({stock_code}): {err}") return None order_id = order_resp.get("output", {}).get("ODNO", "") # 인메모리 포지션 추가 (DB 직접 접근 금지) async with day_trading_state.trade_lock: day_trading_state.add_position(stock_code, { "stock_name": stock_name, "strategy": signal.strategy, "entry_price": current_price, "stop_loss": signal.stop_loss, "target_price": signal.target_price, "quantity": quantity, "highest_price": current_price, "entry_time": datetime.now(_KST).replace(tzinfo=None), "reason": signal.reason, "order_id": order_id, }) # DB 기록 (큐 경유) await enqueue_trade_history({ "stock_code": stock_code, "stock_name": stock_name, "order_type": "BUY", "order_price": current_price, "order_quantity": quantity, "executed_price": current_price, "executed_quantity": quantity, "total_amount": current_price * quantity, "status": "COMPLETED", "order_id": order_id, "entry_reason": signal.reason, "strategy_type": "daytrading", }) await enqueue_active_trade_upsert({ "stock_code": stock_code, "stock_name": stock_name, "quantity": quantity, "entry_price": current_price, "strategy_name": "day_trade", "entry_reason": signal.reason, "stop_loss": signal.stop_loss, "take_profit": signal.target_price, }) logger.info( f"[DayEntry] ✅ 진입: {stock_code} {stock_name} " f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}" ) return { "stock_code": stock_code, "stock_name": stock_name, "quantity": quantity, "price": current_price, "strategy": signal.strategy, "reason": signal.reason, } except Exception as e: logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True) return None