"""스윙 트레이딩 진입 서비스 trade_executor.py의 execute_auto_trade_background() 로직을 기반으로 strategy_type='swing' 진입만 처리하는 독립 서비스. 진입 기준 (CLAUDE.md 섹션 6.2): - BULL: RSI(14) < 46, 가격이 BB 하단 부근, 거래량 > 20MA - BEAR: RSI(14) < 35, 가격 < BB 하단, 거래량 > 1.5x20MA, 반전 캔들 - SIDEWAYS: 진입 없음 (CLAUDE.md 6.1) - 포지션 사이징: 종목당 총자본 6% (MAX_POSITION_SIZE), 최대 25종목 진입 전 체크: 1. 장 오픈 여부 (정규장 09:00~15:30만 허용) 2. 스윙 포지션 한도 (25종목) 3. MDD 신규 진입 가능 여부 4. 적응형 전략 Crisis 모드 차단 5. 플레이북 이벤트 차단 (FOMC, 옵션만기 등) 6. 시장 국면 SIDEWAYS 차단 """ import asyncio import logging from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.config import settings from app.models.models import Portfolio from app.services.trade_executor import execute_auto_trade, execute_auto_trade_background from app.services.market_analyzer import check_market_regime from app.services.mdd_manager import mdd_manager from app.services.swing.position_manager import count_swing_positions from app.services.market_playbook import is_entry_allowed_by_playbook, get_position_multiplier_from_playbook logger = logging.getLogger(__name__) # 스윙 동시 보유 최대 종목 수 (CLAUDE.md 5.1: MAX_HOLDINGS_COUNT = 20이지만, # 스윙 전용으로 25개까지 허용 -- 데이트레이딩과 분리 운영) MAX_SWING_POSITIONS = 25 async def execute_swing_entries(recommendations_response: dict, db: AsyncSession) -> list[dict]: """배치 분석 추천 결과를 기반으로 스윙 매수 실행. 기존 execute_auto_trade_background()를 재활용하되 strategy_type='swing' 레이블을 주입하여 데이트레이딩과 분리. 처리 흐름: 1. 장 오픈 여부 체크 (정규장만 허용) 2. 포지션 한도 체크 (MAX_SWING_POSITIONS) 3. MDD 체크 (can_open_new_position) 4. 적응형 전략 Crisis 모드 차단 5. 플레이북 이벤트 차단 6. SIDEWAYS 국면 차단 7. 추천 목록에 strategy_type='swing' 주입 8. execute_auto_trade_background() 호출 Args: recommendations_response: Redis 또는 DB에서 가져온 추천 딕셔너리 - recommendations_by_rule: 규칙별 추천 종목 리스트 db: AsyncSession 인스턴스 Returns: list[dict]: 실행된 매수 결과 목록 """ # 장 마감 후 매수 차단 (15:30 이후, KIS 모의투자 24시간 허용 방지) from app.core.market_schedule import is_market_open if not is_market_open(): logger.info("[SwingEntry] 장 마감 — 스윙 매수 차단 (정규장만 매매)") return [] # 포지션 한도 체크 current_count = await count_swing_positions(db) if current_count >= MAX_SWING_POSITIONS: logger.info( f"[SwingEntry] 최대 포지션 수 도달 ({current_count}/{MAX_SWING_POSITIONS}) — 신규 진입 스킵" ) return [] # MDD 체크 can_open = await mdd_manager.can_open_new_position() if not can_open: logger.warning("[SwingEntry] MDD 초과 — 신규 진입 차단") return [] # 적응형 전략 체크 — Crisis 모드에서 스윙 진입 전면 차단 try: from app.services.adaptive_strategy import get_current_adaptive_params _adp_swing = get_current_adaptive_params() if not _adp_swing.fallback_used and _adp_swing.trading_mode == "crisis": logger.warning("[SwingEntry] 적응형 전략 CRISIS 모드 — 스윙 진입 전면 차단") return [] except Exception: pass # adaptive 모듈 실패 시 기존 로직 유지 # 플레이북 체크 — FOMC/옵션만기/서킷브레이커 등 이벤트 당일 차단 if not is_entry_allowed_by_playbook(): logger.info("[SwingEntry] 플레이북 차단 — 신규 진입 비활성화 (이벤트 대응)") return [] # SIDEWAYS 국면 체크 try: regime = await check_market_regime() regime_str = regime.get("regime_200d", "bull") if isinstance(regime, dict) else "bull" if regime_str == "sideways": logger.info("[SwingEntry] SIDEWAYS 국면 — 진입 신호 무시 (CLAUDE.md 6.1)") return [] except Exception as e: logger.warning(f"[SwingEntry] 시장 국면 조회 실패: {e}") # strategy_type='swing' 레이블 주입 rec = dict(recommendations_response) _inject_strategy_type(rec, "swing") # 기존 execute_auto_trade_background 재활용 (시그니처 변경 없음) try: result = await execute_auto_trade_background(rec, db) if isinstance(result, list): return result except Exception as e: logger.error(f"[SwingEntry] 진입 실행 오류: {e}", exc_info=True) return [] def _inject_strategy_type(rec: dict, strategy_type: str) -> None: """추천 목록의 각 종목에 strategy_type 필드를 주입. recommendations_by_rule 구조가 리스트 또는 딕셔너리일 수 있어 두 가지 형태를 모두 지원. Args: rec: 추천 딕셔너리 (원본을 직접 수정) strategy_type: 주입할 전략 타입 (예: "swing") """ groups = rec.get("recommendations_by_rule", []) if isinstance(groups, list): for group in groups: if isinstance(group, dict): for stock in group.get("stocks", []): if isinstance(stock, dict): stock["strategy_type"] = strategy_type elif isinstance(groups, dict): for group in groups.values(): if isinstance(group, list): for stock in group: if isinstance(stock, dict): stock["strategy_type"] = strategy_type