"""자동매매 실행 엔진 (trade_executor.py) 흐름 요약: execute_auto_trade_background() ← 추천 목록 수신 └─ execute_auto_trade() ← 종목별 공통 필터 (가격·MDD·신호 등) ├─ _execute_buy() ← 매수 로직 (급등체크→트렌드→포지션사이징→주문) └─ _execute_sell() ← 매도 로직 (중복체크→주문→DB기록) └─ _verify_and_sync_execution() ← 체결 확인·슬리피지 기록·부분체결 재주문 """ import asyncio import logging import time from decimal import Decimal from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, and_, or_ from app.core.config import settings from app.core.database import AsyncSessionLocal from app.models.models import TradeHistory, Portfolio from app.services.korea_investment_api import korea_investment_api from app.services.mdd_manager import mdd_manager from app.services.loss_tracker import loss_tracker from app.services.market_analyzer import check_market_regime, _get_kst_now from app.services.mean_reversion_strategy import ( MarketRegime, calculate_position_size_v2, check_exit_signal, calculate_rsi, calculate_bollinger, calculate_atr, ) from app.services.technical_indicators import ( TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema, ) # §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음) from app.services.sizing_core import ( amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction, ) from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS from app.services.correlation_filter import validate_correlation_filter from app.services.exchange_detector import is_us_stock, detect_exchange, get_market_info from app.core.timeutil import now_kst from app.services.trade_helpers import ( check_opening_range_volatility, check_surge_prevention, check_trend_filter, calculate_cooldown_minutes, get_daily_cutoff_utc, get_leverage_check_keywords, is_leverage_etf, calculate_profit_loss_rate, calculate_atr_based_stops, build_kis_order_id, calculate_trade_costs, ) logger = logging.getLogger(__name__) # ── 잔량 추적 해석 (CLAUDE.md §8.5 항목4) ───────────────────────────────────── def _resolve_remaining_pct_v2(stock: dict, holding=None) -> tuple[float, bool]: """remaining_pct 와 '잔량 추적 가능 여부' 를 함께 반환. 기존 `stock.get("remaining_pct", 1.0) or 1.0` 은 '추적 소스 없음(None)' 과 '실제로 잔량 100%' 를 구분하지 못했다. ActiveTrade 행이 없는 보유분 (브로커 잔고 sync 로 생긴 Portfolio 행 / legacy 행)은 매 사이클 잔량 100%로 간주되어 SELL_PARTIAL(50%)이 무한 반복됐다. Args: stock: 종목 dict (remaining_pct 주입 대상) holding: Portfolio ORM (현재 판정에 미사용 — 향후 확장용, 시그니처 고정) Returns: (remaining_pct, is_tracked) - is_tracked=False → 잔량을 신뢰할 수 없음. 호출측은 부분매도를 스킵하고 전량 청산 신호만 채택해야 한다. 값은 기존 동작과 동일한 1.0 을 준다. - remaining_pct 는 항상 0 < x <= 1.0 범위로 클램프. """ raw = stock.get("remaining_pct") if isinstance(stock, dict) else None if raw is None: return 1.0, False try: val = float(raw) except (TypeError, ValueError): return 1.0, False if not (0.0 < val <= 1.0): # 0 이하(전량 소진) 또는 1 초과(오염) → 추적 불가로 간주 return 1.0, False return val, True # ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ──────────────────────────────── _order_semaphores: dict = {} _SEMAPHORE_MAX_SIZE = 200 def _cleanup_semaphores() -> None: """세마포어 딕셔너리 크기가 임계값 초과 시 미사용 항목 제거. [동작] sem._value == 1 이면 현재 아무도 잡고 있지 않은 상태(unlock). 장기 운영 시 거래된 적 있는 모든 종목 코드가 누적되는 것을 방지. """ if len(_order_semaphores) <= _SEMAPHORE_MAX_SIZE: return # 임계값 이하 → 정리 불필요 to_delete = [ code for code, sem in _order_semaphores.items() if sem._value == 1 # unlocked = 현재 사용 중 아님 ] for code in to_delete: _order_semaphores.pop(code, None) if to_delete: logger.debug(f"[SEMAPHORE_CLEANUP] {len(to_delete)}개 세마포어 정리 (잔여: {len(_order_semaphores)})") def _get_order_semaphore(stock_code: str) -> asyncio.Semaphore: """종목별 세마포어 반환. 없으면 생성. 크기 초과 시 미사용 항목 정리. [동작] 동일 종목에 대해 동시에 2개 이상의 주문이 발생하지 않도록 asyncio.Semaphore(1) 으로 상호 배제. 코드 진입 시 lock → 완료 시 release. """ if stock_code not in _order_semaphores: _cleanup_semaphores() # 200개 초과 시 미사용 세마포어 정리 _order_semaphores[stock_code] = asyncio.Semaphore(1) return _order_semaphores[stock_code] # ── Redis 분산락 — 중복 주문 완전 방지 (TTL 60초) ──────────────────────────── _ORDER_LOCK_TTL = 60 # seconds async def _acquire_order_lock(stock_code: str, order_type: str) -> bool: """Redis SETNX 기반 분산락 획득. 성공 시 True. [동작] Redis SET NX(NOT EXISTS) + EX(TTL 60초)로 원자적 락 설정. - 동일 종목·방향의 주문이 이미 처리 중이면 False → 호출측에서 SKIP. - Redis 미연결 시 True(락 없이 진행) — 이 경우 세마포어가 2차 방어. """ try: from app.core.redis import get_redis client = get_redis() if not client: return True # Redis 미연결 → 락 없이 진행 (세마포어가 대신 보호) key = f"order_lock:{stock_code}:{order_type}" result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL) return result is True # None이면 이미 키 존재 → 락 실패 except Exception as e: logger.debug(f"[order_lock] 획득 오류 (패스): {e}") return True async def _release_order_lock(stock_code: str, order_type: str) -> None: """Redis 분산락 해제. [동작] finally 블록에서 반드시 호출 — 주문 성공/실패 관계없이 락 해제. """ try: from app.core.redis import delete_key await delete_key(f"order_lock:{stock_code}:{order_type}") except Exception: pass # 해제 실패 시 TTL(60초) 만료 후 자동 해제됨 # ── 주문 재시도 — Exponential backoff (1→2→4초, 최대 3회) ─────────────────── async def _place_order_with_retry( is_domestic: bool, stock_code: str, order_type: str, quantity: int, price: float, exchange_code: str = "NASD", max_retries: int = 3, ) -> dict: """주문 실행 with exponential backoff. 시스템 오류에만 재시도. [동작] 1. 국내/해외 분기 후 KIS REST API 호출 2. rt_cd "0"(성공) 또는 "1"(PENDING) → 즉시 반환 (재시도 불필요) 3. 그 외 오류: 1초→2초→4초 대기 후 최대 max_retries회 재시도 4. 최대 재시도 소진 시 last_result(마지막 응답) 반환 """ last_result: dict = {} for attempt in range(max_retries): try: if is_domestic: # 국내 주식 주문 (KIS TTTC0012U / VTTC0012U) result = await korea_investment_api.place_order(stock_code, order_type, quantity, price) else: # 해외 주식 주문 (KIS TTTT1002U 등) result = await korea_investment_api.place_overseas_order( stock_code, order_type, quantity, float(price), exchange_code ) # rt_cd "0"=성공, "1"=PENDING → 재시도 불필요 if result.get("rt_cd") in ("0", "1"): return result # rt_cd 오류 → 재시도 대상 last_result = result if attempt < max_retries - 1: wait = 1 * (2 ** attempt) # 1s → 2s → 4s (exponential backoff) logger.warning( f"[ORDER_RETRY] {stock_code} {order_type} rt_cd={result.get('rt_cd')} " f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries})" ) await asyncio.sleep(wait) except Exception as e: # 네트워크 타임아웃 등 예외 → 재시도 대상 # [2026-09-23] 단, KIS가 주문을 접수한 뒤 응답만 못 받은 경우가 있으므로 # 재시도 전에 당일 체결 내역으로 중복 여부를 확인한다(멱등성 보강). last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None} if is_domestic: _dup = await _find_recent_order(stock_code, order_type, quantity) if _dup: logger.warning( f"[ORDER_DUP] {stock_code} {order_type} 예외 발생했으나 주문 접수 확인됨 " f"(odno={_dup.get('odno')}) → 재시도 중단" ) return _dup if attempt < max_retries - 1: wait = 1 * (2 ** attempt) logger.warning(f"[ORDER_RETRY] {stock_code} {order_type} 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {type(e).__name__}: {e}") await asyncio.sleep(wait) else: logger.error(f"[ORDER_MAX_RETRY] {stock_code} {order_type} 최대 재시도 초과: {type(e).__name__}: {e}") return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None} async def _find_recent_order(stock_code: str, order_type: str, quantity: int) -> dict | None: """직전 주문이 실제로 KIS에 접수됐는지 당일 주문체결 내역으로 확인. 타임아웃/연결 끊김으로 응답을 못 받았지만 주문은 접수된 경우, 그대로 재시도하면 중복 체결이 된다. 최근 2분 내 같은 종목·매매구분·수량 주문이 있으면 그 주문을 반환한다. 조회 실패 시 None (판단 불가 → 호출측의 기존 재시도 로직을 따른다). """ try: rows = await korea_investment_api.get_todays_orders(stock_code) except Exception as e: logger.debug(f"[ORDER_DUP] 주문내역 조회 실패 ({stock_code}): {e}") return None if not rows: return None _now = datetime.now() _side = "02" if order_type.upper() == "BUY" else "01" # KIS: 01=매도, 02=매수 for row in rows: try: if row.get("pdno") != stock_code: continue if row.get("sll_buy_dvsn_cd") not in (_side, None, ""): continue if int(row.get("ord_qty", 0) or 0) != int(quantity): continue _t = str(row.get("ord_tmd") or "") # HHMMSS if len(_t) == 6: _ordered = _now.replace( hour=int(_t[:2]), minute=int(_t[2:4]), second=int(_t[4:]), microsecond=0 ) if (_now - _ordered).total_seconds() > 120: continue return { "rt_cd": "0", "msg1": "기존 주문 확인(중복 방지)", "odno": row.get("odno"), "output": {"ODNO": row.get("odno"), "ord_prc": row.get("ord_unpr")}, } except Exception: continue return None # ── [C1] 공통 주문 래퍼 — 분산락+세마포어 보호 (daytrading/swing에서 import) ── async def safe_place_order( stock_code: str, order_type: str, quantity: int, price: float | None = None, is_domestic: bool = True, exchange_code: str = "NASD", ) -> dict: """Redis 분산락 + 세마포어로 보호된 주문 실행. daytrading/exit_service, swing/exit_service에서 korea_investment_api.place_order() 대신 이 함수를 사용하여 이중 주문을 방지합니다. """ if not await _acquire_order_lock(stock_code, order_type): logger.warning(f"[SAFE_ORDER] {stock_code} {order_type} — 분산락 획득 실패 (이미 처리 중)") return {"rt_cd": "-1", "msg1": "order_lock_busy"} try: async with _get_order_semaphore(stock_code): if is_domestic: result = await korea_investment_api.place_order( stock_code=stock_code, order_type=order_type, quantity=quantity, price=price, ) else: result = await korea_investment_api.place_overseas_order( symbol=stock_code, order_type=order_type, quantity=quantity, price=price or 0, exchange_code=exchange_code, ) return result or {"rt_cd": "-1", "msg1": "no_response"} except Exception as e: logger.error(f"[SAFE_ORDER] {stock_code} {order_type} 실패: {e}") return {"rt_cd": "-1", "msg1": str(e)} finally: await _release_order_lock(stock_code, order_type) # ── [멀티테넌트 1단계 §5.8] 사용자 스코프 주문 락/래퍼 ──────────────────────── # 기존 safe_place_order/_acquire_order_lock/_auto_cancel_if_pending는 시그니처 # 불변 유지. 사용자별 계좌(ctx.kis)로 주문하는 경로는 아래 _v2 함수를 사용한다. async def _acquire_order_lock_v2(user_id, stock_code: str, order_type: str) -> bool: """Redis 분산락 획득 — 사용자 네임스페이스 키(`order_lock:{user_id}:{code}:{type}`). 구조는 `_acquire_order_lock`과 동일(SETNX+TTL 60초). legacy 컨텍스트(user_id가 None 또는 legacy owner id)도 이 함수를 쓰면 키에 user_id가 섞이므로, legacy 경로는 기존 `_acquire_order_lock`(user_id 없는 키)을 그대로 사용해야 한다. """ try: from app.core.redis import get_redis client = get_redis() if not client: return True key = f"order_lock:{user_id}:{stock_code}:{order_type}" result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL) return result is True except Exception as e: logger.debug(f"[order_lock_v2] user_id={user_id} 획득 오류 (패스): {e}") return True async def _release_order_lock_v2(user_id, stock_code: str, order_type: str) -> None: """Redis 분산락 해제 — `_acquire_order_lock_v2`와 짝을 이루는 사용자 네임스페이스 키.""" try: from app.core.redis import delete_key await delete_key(f"order_lock:{user_id}:{stock_code}:{order_type}") except Exception: pass async def safe_place_order_v2( ctx, # UserContext (순환 임포트 회피 — 타입은 app.services.tenant.context.UserContext) stock_code: str, order_type: str, quantity: int, price: float | None = None, is_domestic: bool = True, exchange_code: str = "NASD", ) -> dict: """`safe_place_order`의 사용자 스코프 버전 (설계 문서 §5.8). - `ctx.is_legacy`면 기존 전역 경로(`_acquire_order_lock`/`_release_order_lock`, `_get_order_semaphore(stock_code)`)를 그대로 사용 — swing/daytrading exit_service 등 기존 스케줄러가 잡는 락과 같은 키여야 같은 계좌·종목 동시 매도가 직렬화된다. 사용자 컨텍스트(`is_legacy=False`)만 사용자 네임스페이스 키(`{user_id}:{code}`)를 쓴다. - 세마포어: `_get_order_semaphore(f"{ctx.user_id}:{stock_code}")` — 기존 함수 재사용, 키 문자열만 사용자 네임스페이스로 바꿈(시그니처 불변). - Redis 락: `_acquire_order_lock_v2`/`_release_order_lock_v2` (legacy는 v1 그대로). - 주문 실행: `ctx.kis.place_order(...)`/`ctx.kis.place_overseas_order(...)`. - 반환 형식은 `safe_place_order`와 동일. """ if ctx.is_legacy: semaphore_key = stock_code lock_acquired = await _acquire_order_lock(stock_code, order_type) else: semaphore_key = f"{ctx.user_id}:{stock_code}" lock_acquired = await _acquire_order_lock_v2(ctx.user_id, stock_code, order_type) if not lock_acquired: logger.warning(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} — 분산락 획득 실패 (이미 처리 중)") return {"rt_cd": "-1", "msg1": "order_lock_busy"} try: async with _get_order_semaphore(semaphore_key): if is_domestic: result = await ctx.kis.place_order( stock_code=stock_code, order_type=order_type, quantity=quantity, price=price, ) else: result = await ctx.kis.place_overseas_order( symbol=stock_code, order_type=order_type, quantity=quantity, price=price or 0, exchange_code=exchange_code, ) return result or {"rt_cd": "-1", "msg1": "no_response"} except Exception as e: logger.error(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} 실패: {e}") return {"rt_cd": "-1", "msg1": str(e)} finally: if ctx.is_legacy: await _release_order_lock(stock_code, order_type) else: await _release_order_lock_v2(ctx.user_id, stock_code, order_type) async def _auto_cancel_if_pending_v2(ctx, order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None: """`_auto_cancel_if_pending`의 사용자 스코프 버전 — `ctx.kis.cancel_order`를 사용.""" await asyncio.sleep(delay) try: result = await ctx.kis.cancel_order(order_id, quantity) if result.get("rt_cd") == "0": logger.info(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} 미체결 자동 취소 완료: {stock_code} order_id={order_id}") else: logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)") except Exception as e: logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {type(e).__name__}: {e}") # ── 미체결 주문 1분 후 자동 취소 ───────────────────────────────────────────── async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None: """delay초 후에도 PENDING 상태면 자동 취소. [동작] 주문 후 delay(기본 60초)를 기다린 뒤 KIS 취소 API 호출. 이미 체결됐다면 취소 실패(msg1에 오류 메시지) → DEBUG 로그만 남기고 무시. 호출 측에서 asyncio.create_task() 로 백그라운드 실행. """ await asyncio.sleep(delay) # 60초 대기 후 취소 시도 try: result = await korea_investment_api.cancel_order(order_id, quantity) if result.get("rt_cd") == "0": logger.info(f"[AUTO_CANCEL] 미체결 자동 취소 완료: {stock_code} order_id={order_id}") else: # 이미 체결됐거나 취소 불가 상태 → 무시 logger.debug(f"[AUTO_CANCEL] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)") except Exception as e: logger.debug(f"[AUTO_CANCEL] {stock_code} — {type(e).__name__}: {e}") # ── 계좌 정보 30초 인메모리 캐시 ────────────────────────────────────────────── _account_cache: dict = {"data": None, "ts": 0.0} _ACCOUNT_CACHE_TTL = 30 # seconds def _get_cached_account() -> tuple | None: """(available_cash, total_assets) 30초 캐시. 만료 시 None 반환. [동작] monotonic clock으로 TTL 검사. 만료되지 않았으면 캐시 반환, 만료됐거나 데이터가 없으면 None → 호출측에서 portfolio_cache 재조회. """ if _account_cache["data"] and time.monotonic() - _account_cache["ts"] < _ACCOUNT_CACHE_TTL: return _account_cache["data"] return None def _set_account_cache(available_cash: float, total_assets: float) -> None: """계좌 캐시 갱신. 현재 시각(monotonic)을 타임스탬프로 저장.""" _account_cache["data"] = (available_cash, total_assets) _account_cache["ts"] = time.monotonic() # ── 지정가 조정 — 체결률 향상 ────────────────────────────────────────────────── def _adjust_order_price(price: float, order_type: str, is_domestic: bool = True) -> float: """BUY +0.5% / SELL -0.5% 지정가 조정. 국내: 원화 정수 반환 (KIS는 원화 단위 정수만 허용). 해외: 소수점 2자리 반환 (USD 단위). """ if order_type == "BUY": adjusted = price * 1.005 else: adjusted = price * 0.995 if is_domestic: return float(int(adjusted)) # 원화 정수 return round(adjusted, 2) # USD 소수점 2자리 # ── Kelly Criterion 포지션 사이징 ───────────────────────────────────────────── async def _calculate_kelly_position( db: AsyncSession, stock_code: str, total_assets: float, available_cash: float, max_per_position: float = 0.04, lookback: int = 20, ) -> float: """Kelly Criterion 기반 최적 포지션 사이징 (Half-Kelly 적용). f* = (p*b - (1-p)) / b - p: 최근 N거래 승률 (이긴 거래 수 / 전체 거래 수) - b: 평균이익률 / 평균손실률 (Payoff Ratio) - Half-Kelly: f* / 2 → 변동성 감소 목적 [동작] 1. DB에서 최근 lookback회 매도 체결 기록 조회 2. 5거래 미만이면 통계 신뢰 부족 → 0.0 반환 (호출측에서 v2 fallback) 3. 반환값 = min(total_assets×clamped, available_cash, max_per_position 한도) """ try: recent = await db.execute( select(TradeHistory) .where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", TradeHistory.status == "COMPLETED", TradeHistory.profit_loss_rate.isnot(None), ) .order_by(desc(TradeHistory.executed_at)) .limit(lookback) ) trades = recent.scalars().all() if len(trades) < 5: return 0.0 # 데이터 부족 → v2 fallback rates = [float(t.profit_loss_rate) for t in trades] # [§8.5-2 통합 2026-09-27] Kelly 통계 수식은 sizing_core.half_kelly_fraction 로 이동 # (순수 계산). DB 조회·예외 처리·fallback 판단은 이 함수에 남는다. # min_trades=5 미만 / 전승 / 전패 → None → 기존과 동일하게 0.0 반환(v2 fallback). half_kelly = half_kelly_fraction(rates, min_trades=5) if half_kelly is None: return 0.0 clamped = min(half_kelly, max_per_position) # 종목당 최대 4% 상한 적용 # = min(총자산×clamped, 가용현금, 총자산×max_per_position) — 통합 전과 동일 return clamp_by_ratios( total_assets * clamped, total_assets, [max_per_position], available_cash, ) except Exception as e: logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}") return 0.0 # ── 체결 확인 + 부분 체결 처리 + 슬리피지 로깅 ─────────────────────────────── async def _verify_and_sync_execution( order_id: str, stock_code: str, stock_name: str, ordered_quantity: int, order_price: float, order_type: str, is_domestic: bool, exchange_code: str, db_trade_id: int, delay: int = 3, ) -> None: """주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문. [동작 흐름] 1. delay초 대기 (체결 확인 전 KIS 서버 처리 시간 확보) 2. KIS 체결 조회 API 호출 → filled_qty, filled_price 추출 3. 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 4. db_worker 큐를 통해 TradeHistory UPDATE (CLAUDE.md 3.2 준수) - 완전 체결(filled_qty == ordered_quantity) → status = "COMPLETED" - 부분 체결(filled_qty < ordered_quantity) → status = "PARTIAL" + 잔여 재주문 5. 포트폴리오 동기화 트리거 """ await asyncio.sleep(delay) # KIS 서버 체결 처리 대기 if not order_id: # order_id 없으면 체결 조회 불가 → 포트폴리오 동기화만 수행 await _delayed_sync_portfolio(0) return try: # KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R) # [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응 filled_qty = 0 filled_price = 0.0 exec_result = {} max_polls = 5 poll_interval = 3 for poll in range(max_polls): exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic) filled_qty = int(exec_result.get("filled_qty", 0)) filled_price = float(exec_result.get("filled_price", order_price) or order_price) if filled_qty > 0: break # 체결 확인됨 if poll < max_polls - 1: logger.debug( f"[VERIFY_EXEC] {stock_code} {order_type} 체결 미확인 " f"({poll + 1}/{max_polls}), {poll_interval}초 후 재시도" ) await asyncio.sleep(poll_interval) if filled_qty == 0: logger.warning( f"[VERIFY_EXEC] {stock_code} {order_type} " f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id}" ) # ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ───────────────── slippage_pct = 0.0 if order_price > 0 and filled_price > 0: slippage_pct = ((filled_price - order_price) / order_price) * 100 logger.info( f"[SLIPPAGE] {stock_code} {order_type} " f"주문가={order_price:.2f} 체결가={filled_price:.2f} " f"슬리피지={slippage_pct:+.3f}%" ) # ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ─── if filled_qty > 0: partial = filled_qty < ordered_quantity # 부분 체결 여부 update_fields = { "executed_quantity": filled_qty, "executed_price": Decimal(str(filled_price)), "status": "PARTIAL" if partial else "COMPLETED", "executed_at": _get_kst_now(), "slippage": Decimal(str(round(slippage_pct, 4))), } if db_trade_id: # 기존 경로: trade_id로 조회 (레거시 호출 호환) from app.services.db_worker import enqueue_update_trade_history await enqueue_update_trade_history(db_trade_id, update_fields) else: # 신규 경로: order_id로 조회 (db_worker 큐 경유 INSERT 시 trade_id 미확보) from app.services.db_worker import enqueue_update_trade_history_by_order_id await enqueue_update_trade_history_by_order_id(order_id, update_fields) # ── 부분 체결 → 미체결 수량 재주문 ────────────────────── if partial: remain = ordered_quantity - filled_qty logger.warning( f"[PARTIAL_FILL] {stock_code} {order_type} " f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문" ) # 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지) asyncio.create_task( _reorder_unfilled( stock_code=stock_code, stock_name=stock_name, order_type=order_type, quantity=remain, price=order_price, is_domestic=is_domestic, exchange_code=exchange_code, ) ) await _delayed_sync_portfolio(0) # KIS 잔고 기준으로 포트폴리오 재동기화 except Exception as e: logger.warning( f"[VERIFY_EXEC] {stock_code} {order_type} 체결 확인 실패 — " f"{type(e).__name__}: {e} (포트폴리오 동기화만 실행)" ) await _delayed_sync_portfolio(0) # 체결 확인 실패해도 포트폴리오는 동기화 # ── 부분 체결 후 미체결 수량 재주문 ────────────────────────────────────────── async def _reorder_unfilled( stock_code: str, stock_name: str, order_type: str, quantity: int, price: float, is_domestic: bool, exchange_code: str = "NASD", ) -> None: """부분 체결 잔여 수량 재주문 (1회 한정).""" try: result = await _place_order_with_retry( is_domestic=is_domestic, stock_code=stock_code, order_type=order_type, quantity=quantity, price=price, exchange_code=exchange_code, max_retries=2, ) if result.get("rt_cd") == "0": logger.info(f"[REORDER] {stock_code} {order_type} 미체결 {quantity}주 재주문 성공") else: logger.warning(f"[REORDER] {stock_code} {order_type} 미체결 재주문 실패: {result.get('msg1')}") except Exception as e: logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}") async def _execute_buy( stock: dict, db: AsyncSession, holding, current_price: float, signals: list, score_val: float, is_domestic: bool, exchange_code: str, stock_code: str, stock_name: str, ) -> dict: """BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → 체결 확인.""" # ── 0. 장 시작 30분 변동성 회피 (09:00~09:30 진입 차단) ────────────────── if await check_opening_range_volatility(): logger.debug(f"[BUY_SKIP] {stock_code} — 장 시작 30분 변동성 회피 (09:00~09:30)") return {"action": "SKIP", "stock": stock_name, "reason": "opening_range_volatility"} # ── 0.5. 실적 필터 (Phase 2 — 기본 OFF) ────────────────────────────────── # FUNDAMENTAL_FILTER_ENABLED=True일 때만 동작. 적자(EPS<0)·하락전망(컨센서스 # 목표가 여력 부족) 종목 진입 차단. 데이터원=네이버. **point-in-time 백테스트 # 검증(OpenDART) 전엔 켜지 말 것.** 실패 시 차단 안 함(안전). if settings.FUNDAMENTAL_FILTER_ENABLED and is_domestic: try: from app.services.naver_finance import get_stock_fundamentals _ff = await get_stock_fundamentals(stock_code) if _ff: if settings.FUNDAMENTAL_BLOCK_NEGATIVE_EPS and _ff.get("eps"): try: _eps_v = float(str(_ff["eps"]).replace(",", "").replace("원", "")) if _eps_v < 0: return {"action": "HOLD", "stock": stock_name, "reason": "fundamental_negative_eps(적자)"} except Exception: pass _tp = _ff.get("target_price") if _tp and current_price: try: _upside = (float(str(_tp).replace(",", "")) / current_price - 1) * 100 if _upside < settings.FUNDAMENTAL_MIN_UPSIDE_PCT: return {"action": "HOLD", "stock": stock_name, "reason": f"fundamental_low_upside(목표가 여력 {_upside:+.0f}%)"} except Exception: pass except Exception as _ff_err: logger.debug(f"[FUND_FILTER] {stock_code} 실적필터 스킵: {_ff_err}") # ── 1. 3일 급등 체크 (ATR 기반 동적 임계값) ─────────────────────────────── surge_check = await check_surge_prevention(stock_code, is_domestic, exchange_code, current_price) if surge_check.get("skip"): return {"action": "HOLD", "stock": stock_name, "reason": surge_check.get("reason", "chase_buy_prevention")} # ── 2. 트렌드 필터 (MA + EMA 병행) ─────────────────────────────────────── trend_check = await check_trend_filter(stock_code, stock_name, is_domestic, exchange_code, current_price, stock) if trend_check.get("skip"): return {"action": "HOLD", "stock": stock_name, "reason": trend_check.get("reason", "trend_filter")} # ── 3. 현금·자산 조회 (30초 캐시 → portfolio_cache → KIS API 직접) ───── cached = _get_cached_account() # [FIX] 현금이 0인 캐시는 신뢰하지 않는다. 기존엔 available_cash=0이 캐시되면 # 그대로 재사용해 수량이 0으로 계산되어 매수 주문이 영영 안 나갔다(무한루프). if cached and cached[0] > 0: available_cash, total_assets = cached else: available_cash, total_assets = 0.0, 0.0 # 1차: portfolio_cache 확인 try: from app.api.endpoints.portfolio import portfolio_cache summary_copy = portfolio_cache.get_summary_copy() if summary_copy: available_cash = float(summary_copy.get("available_cash", 0)) total_assets = float(summary_copy.get("total_assets", 0)) except Exception: pass # 2차: 현금이 0이면 KIS API로 직접 조회. # [FIX] 기존 조건은 (cash<=0 AND assets<=0) 이라, 평가자산은 있는데 캐시 현금만 # 0인 경우(portfolio_cache 요약이 available_cash=0을 준 케이스) KIS 재조회를 # 건너뛰어 현금 0이 고착됐다. 현금이 0이면 자산과 무관하게 실제 예수금을 읽는다. if available_cash <= 0: try: balance = await korea_investment_api.get_balance() # NOTE: output2가 빈 리스트([])일 때 balance.get()의 default([{}])는 적용되지 않으므로 # 아래 `if output2:` 가드가 빈 리스트 케이스를 걸러낸다 (IndexError 방지 + 기존 # available_cash/total_assets 캐시값을 의도적으로 덮어쓰지 않고 보존). output2 = balance.get("output2", [{}]) if output2: b = output2[0] if isinstance(output2, list) else output2 available_cash = float(b.get("dnca_tot_amt", 0)) total_assets = float(b.get("tot_evlu_amt", 0)) logger.info(f"[CASH_INIT] KIS API 직접 조회: cash={available_cash:,.0f} assets={total_assets:,.0f}") except Exception as e: logger.warning(f"[CASH_INIT] KIS 잔고 조회 실패: {e}") # [FIX] 실제 현금(>0)일 때만 캐시한다. 0 현금을 캐시하면 위 무한루프가 재발한다. if available_cash > 0: _set_account_cache(available_cash, total_assets) # [2026-09-23] TradeHistory.created_at 은 KST 저장(now_kst)이므로 # daily_cutoff도 UTC 기준으로 계산해야 9시간 오차가 없음. daily_cutoff = get_daily_cutoff_utc() # 오늘 00:00:00 UTC # ── 4. PENDING 포함 오늘 중복 주문 차단 ────────────────────────────────── # 오늘 이미 BUY 주문이 있으면(COMPLETED 또는 PENDING 상태) 재진입 방지 pending_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status.in_(["EXECUTED", "COMPLETED", "PENDING"]), TradeHistory.created_at >= daily_cutoff, ) ) if pending_check.scalars().first(): return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"} # ── 5. 일 매수 횟수 제한 (settings.MAX_DAILY_BUYS_PER_STOCK) ───────────── # 종목당 당일 최대 매수 횟수를 초과하면 추가 매수 차단 daily_buy_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status.in_(["EXECUTED", "COMPLETED"]), TradeHistory.created_at >= daily_cutoff, ) ) if len(daily_buy_check.scalars().all()) >= settings.MAX_DAILY_BUYS_PER_STOCK: return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} # ── 6. 쿨다운 체크 (ATR 기반 동적) ────────────────────────────────────── cooldown_min = calculate_cooldown_minutes(stock, current_price) cooldown_check = await db.execute( select(TradeHistory) .where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status.in_(["EXECUTED", "COMPLETED"]), TradeHistory.created_at >= daily_cutoff, ) .order_by(desc(TradeHistory.created_at)) # 가장 최근 매수부터 ) recent_buys = cooldown_check.scalars().all() # 마지막 매수 이후 쿨다운 시간이 지나지 않았으면 HOLD # created_at·현재시각 모두 KST naive 기준 (예전에는 UTC now 와 KST created_at 을 # 비교해 차이가 음수가 되어 쿨다운이 항상 발동했다) if recent_buys and (now_kst() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} # ── 6.5. 상관관계 필터 (CLAUDE.md 섹션 5.4) ────────────────────────────────── # 섹터 집중도 + 포트폴리오 상관계수 필터 current_holdings = [] # 기본값 (상관관계 필터 실패 시에도 레버리지 한도 체크 가능) try: # 현재 포트폴리오 보유 종목 조회 portfolio_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) current_holdings = portfolio_result.scalars().all() # 상관관계 필터 검증 is_valid, filter_reason = await validate_correlation_filter( stock_code=stock_code, portfolio_holdings=current_holdings, max_per_sector=settings.MAX_STOCKS_PER_SECTOR if hasattr(settings, 'MAX_STOCKS_PER_SECTOR') else 3, correlation_threshold=settings.CORRELATION_THRESHOLD if hasattr(settings, 'CORRELATION_THRESHOLD') else 0.7, lookback_days=60 ) if not is_valid: logger.info(f"[CORRELATION_FILTER] {stock_code}/{stock_name}: {filter_reason}") return {"action": "HOLD", "stock": stock_name, "reason": filter_reason} logger.debug(f"[CORRELATION_FILTER] {stock_code}/{stock_name}: {filter_reason}") except Exception as e: logger.error(f"[CORRELATION_FILTER_ERROR] {stock_code}: {type(e).__name__}: {e}") # 필터 오류 시 진입 거부하지 않음 (데이터 부족 시 정상 동작) pass # ── 6.6. 레버리지/인버스 ETF 20% 한도 체크 (CLAUDE.md 섹션 5.1) ──────────── _leverage_keywords = get_leverage_check_keywords() if is_leverage_etf(stock_name, _leverage_keywords) and total_assets > 0: # 현재 보유 중인 레버리지/인버스 ETF 총 평가액 계산 try: _lev_total = 0.0 for h in current_holdings: h_name = getattr(h, "stock_name", "") or "" if is_leverage_etf(h_name, _leverage_keywords): _lev_total += float(getattr(h, "quantity", 0) or 0) * float(getattr(h, "current_price", 0) or getattr(h, "average_price", 0) or 0) _lev_ratio = _lev_total / total_assets if _lev_ratio >= settings.LEVERAGE_ETF_MAX_RATIO: logger.warning( f"[LEVERAGE_LIMIT] {stock_code}/{stock_name}: " f"레버리지 ETF 비중 {_lev_ratio*100:.1f}% >= {settings.LEVERAGE_ETF_MAX_RATIO*100:.0f}% 한도 초과" ) return {"action": "HOLD", "stock": stock_name, "reason": "leverage_etf_limit_exceeded"} except Exception as e: logger.debug(f"[LEVERAGE_LIMIT] 한도 체크 오류 ({stock_code}): {e}") # ── 7. 포지션 사이징 (Kelly 우선, v2 fallback) ──────────────────────────── # 우선순위: Half-Kelly → calculate_position_size_v2 → available_cash×4% regime_str = stock.get("_regime_str", "bear") # CLAUDE.md 6.1: SIDEWAYS에서는 진입 불가 — 방어코드 if regime_str == "sideways": return {"action": "SKIP", "reason": "sideways_regime", "stock": stock_name} regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR # [이슈7 수정] current_strategy_allocation은 단일 종목 보유액이 아니라 # 해당 전략(mean_reversion) 전체 보유액을 전달해야 포지션 한도가 정확히 계산됨. # current_holdings는 섹션 6.5에서 이미 조회된 Portfolio 리스트를 재활용. strategy_tag = stock.get("strategy_type", "swing") _strategy_total_alloc = 0.0 for _h in current_holdings: _h_strategy = getattr(_h, "strategy_type", None) or "swing" _h_qty = float(getattr(_h, "quantity", 0) or 0) _h_price = float(getattr(_h, "current_price", 0) or getattr(_h, "average_price", 0) or 0) if _h_strategy == strategy_tag: _strategy_total_alloc += _h_qty * _h_price # ── 7.0. 적응형 전략 파라미터 적용 ───────────────────────────────────── # position_size 상한을 적응형 모드에 따라 조정 (Crisis=1.5%, Defensive=4% 등) # # [HIGH-2 수정] settings.MAX_POSITION_SIZE(4%)는 CLAUDE.md §5.1/§8.2가 규정한 # "종목당 절대 상한(hard ceiling)"이다. adaptive_strategy._MODE_PARAMS 는 문서·백테스트 # 기준값이므로 그 숫자 자체는 건드리지 않고(Normal 6% / Aggressive 7% 유지), # 소비 지점인 여기서 min()으로 클램프해 절대 상한을 넘지 못하게 한다. # → 결과적으로 Normal/Aggressive의 실효 종목당 비중은 4%로 묶이고, # Defensive(4%)/Crisis(1.5%)처럼 4% 이하인 모드만 적응형 값이 실제로 작동한다. _adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4% = 절대 상한 try: from app.services.adaptive_strategy import get_current_adaptive_params _adp = get_current_adaptive_params() if not _adp.fallback_used: _adaptive_position_cap = min(_adp.position_size, settings.MAX_POSITION_SIZE) # Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.4) if _adp.trading_mode == "crisis" and _adp.max_daily_trades <= 0: return {"action": "SKIP", "reason": "adaptive_crisis_mode", "stock": stock_name} except Exception: pass invest_amount = await _calculate_kelly_position( db, stock_code, total_assets, available_cash, max_per_position=_adaptive_position_cap, ) if invest_amount <= 0: # Kelly 데이터 부족(5거래 미만) → 전략 v2 fallback invest_amount = calculate_position_size_v2( total_assets=total_assets, available_cash=available_cash, current_strategy_allocation=_strategy_total_alloc, regime=regime, rsi=stock.get("rsi"), adx=stock.get("adx_14"), max_per_position=_adaptive_position_cap, ) if invest_amount <= 0: # v2도 0 반환 시(현금 부족 등) → 적응형 position_size 이내 invest_amount = min(available_cash, total_assets * _adaptive_position_cap) # [#9] 글로벌 자본 가드 — 현금이 전체 자본의 (1-MAX_TOTAL_CAPITAL_USAGE)% 미만이면 진입 차단 min_cash_reserve = total_assets * (1 - settings.MAX_TOTAL_CAPITAL_USAGE) if available_cash - invest_amount < min_cash_reserve: invest_amount = max(0, available_cash - min_cash_reserve) if invest_amount <= 0: return {"action": "SKIP", "reason": "global_capital_guard", "stock": stock_name} # [스윙:DT 자본 배분 2026-09-22] 스윙 합산 투자액이 총자산 × SWING_MAX_CAPITAL_RATIO 를 # 넘지 않도록 상한. 스윙 투자액 = 주식 평가액 − 데이트레이딩 포지션 투자액. # (스윙이 자본 91%를 점유해 DT 예산이 남지 않던 문제 → 50:50) _swing_cap_ratio = float(getattr(settings, "SWING_MAX_CAPITAL_RATIO", 1.0) or 1.0) if 0 < _swing_cap_ratio < 1 and total_assets > 0: try: from app.services.daytrading.position_manager import day_trading_state as _dts _dt_amount = sum( float(p.get("entry_price", 0) or 0) * int(p.get("quantity", 0) or 0) for p in _dts.positions.values() ) except Exception: _dt_amount = 0.0 _swing_invested = max(0.0, total_assets - available_cash - _dt_amount) _swing_room = total_assets * _swing_cap_ratio - _swing_invested if invest_amount > _swing_room: invest_amount = max(0.0, _swing_room) if invest_amount <= 0: logger.info( f"[TRADE] {stock_name}: 스윙 자본 상한 도달 " f"({_swing_invested/total_assets:.1%} ≥ {_swing_cap_ratio:.0%}) — 신규 매수 차단" ) return {"action": "SKIP", "reason": "swing_capital_cap", "stock": stock_name} # ── 7.5. 패턴 메모리 기반 포지션 조정 ────────────────────────────────── # 종목별 과거 행동 패턴(지지/저항선, RSI 반등, 승률 등)을 기반으로 # 투자금액을 -30% ~ +30% 범위에서 동적 조정. # CLAUDE.md 5.1 절대 상한(4%)은 조정 후 재적용. try: from app.services.stock_pattern_memory import get_pattern_adjusted_invest_amount invest_amount = await get_pattern_adjusted_invest_amount( stock_code=stock_code, base_invest_amount=invest_amount, rsi=stock.get("rsi"), current_price=current_price, current_volume=float(stock.get("volume", 0) or 0), ) except Exception as _pm_err: # [HIGH-1 수정] 조용한 실패 방지 — debug → warning. logger.warning(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (기본 투자금액 유지): {_pm_err}") # [HIGH-1 수정] CLAUDE.md §5.1 종목당 4% 절대 상한 재적용. # 과거에는 이 클램프가 위 try 블록 안에 있어, get_pattern_adjusted_invest_amount()가 # 예외를 던지면 클램프를 건너뛰고 Kelly/v2가 _adaptive_position_cap(적응형 6~7%)로 # 산출한 금액이 그대로 통과했다. 예외 발생 여부와 무관하게 항상 적용되도록 try 밖으로 이동. # total_assets=0(잔고 조회 실패 등)이면 비율 상한을 계산할 수 없으므로 현금 상한만 적용 # — 0으로 클램프해 전 종목이 quantity_zero로 조용히 탈락하는 것을 방지. # [§8.5-2 통합 2026-09-27] 클램프 수식만 sizing_core 로 위임 — 클램프 **순서** # (글로벌 자본 가드 → 스윙 자본 캡 → 패턴 메모리 → 절대 상한)와 total_assets>0 # 분기는 최근 확정된 동작이므로 그대로 유지한다. if total_assets > 0: invest_amount = clamp_by_ratios( invest_amount, total_assets, [settings.MAX_POSITION_SIZE], available_cash, ) else: invest_amount = min(invest_amount, available_cash) # [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을 # invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음. try: _position_multiplier = float(stock.get("_position_multiplier", 1.0) or 1.0) except Exception: _position_multiplier = 1.0 _position_multiplier = max(0.0, min(1.0, _position_multiplier)) try: _news_position_scale = float(stock.get("_news_position_scale", 1.0) or 1.0) except Exception: _news_position_scale = 1.0 _news_position_scale = max(0.0, min(1.0, _news_position_scale)) if _position_multiplier != 1.0 or _news_position_scale != 1.0: invest_amount = invest_amount * _position_multiplier * _news_position_scale logger.debug( f"[POSITION_SCALE] {stock_code}: position_multiplier={_position_multiplier:.2f} " f"news_scale={_news_position_scale:.2f} → invest_amount={invest_amount:,.0f}" ) # 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지) # [§8.5-2 통합 2026-09-27] sizing_core.amount_to_quantity 위임 — 동일한 # int(Decimal(str(amount))/Decimal(str(price))) 경로. max(1, ...) 없음(예산 0 → 0주). quantity = amount_to_quantity(invest_amount, current_price) logger.info( f"[BUY_CALC] {stock_code}/{stock_name}: cash={available_cash:,.0f} assets={total_assets:,.0f} " f"invest={invest_amount:,.0f} qty={quantity} price={current_price:,.0f}" ) if quantity <= 0: # [관측성] 수량 0 원인을 구분: 배율(플레이북 FOMC·국면·뉴스)로 투자금이 # 0이 됐는지, 아니면 현금 부족인지. "매수 실행 후 조용히 스킵"의 정체를 로그로. if _position_multiplier <= 0 or _news_position_scale <= 0: _qz_reason = "entry_blocked_multiplier_0(플레이북/국면/뉴스로 진입차단)" else: _qz_reason = "quantity_zero(현금/가격 대비 1주 미만)" logger.info(f"[BUY_SKIP] {stock_code}/{stock_name}: {_qz_reason} (invest={invest_amount:,.0f})") return {"action": "SKIP", "reason": _qz_reason, "stock": stock_name} # 최소 매수 금액 가드: 수수료 대비 비효율 방지 (기본 config.MIN_BUY_AMOUNT_KRW, 15만원) # # [MEDIUM-5 문서 불일치 — 의도된 설계] # CLAUDE.md §5.1/§8.2는 최소주문금액을 "15만원 고정"으로 서술하지만, # 실제 구현은 아래와 같이 소액 계좌에서만 자동 하향된다: # 실효하한 = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형포지션상한 × 0.9) # 이유: 총자산이 작으면 종목당 예산(총자산×4%) 자체가 15만원 미만이 되어 # 모든 종목이 min_amount_guard로 영구 차단된다(2026-09-23 하루 141건 차단 사례). # 큰 계좌에서는 항상 config값(15만원)이 더 작게 선택되므로 문서와 동일하게 동작한다. # → 동작을 문서에 맞추는 대신 문서(§5.1)를 이 동작에 맞춰 보정할 예정. 코드 변경 금지. # 예) 총자산 300만원 × 4% × 0.9 = 108,000원이 실효 하한(15만원 아님). # [§8.5-2 통합 2026-09-27] 계산식만 sizing_core.effective_min_buy_amount 로 위임. # = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형cap × 0.9). 로직·계수 변경 없음. try: _MIN_BUY_AMOUNT = effective_min_buy_amount( config_min_amount=settings.MIN_BUY_AMOUNT_KRW, total_assets=total_assets, position_cap_ratio=_adaptive_position_cap, factor=0.9, ) except Exception: _MIN_BUY_AMOUNT = settings.MIN_BUY_AMOUNT_KRW _actual_amount = quantity * current_price if _actual_amount < _MIN_BUY_AMOUNT: logger.info(f"[SKIP] {stock_code}: 매수금액 {_actual_amount:,.0f}원 < 최소 {_MIN_BUY_AMOUNT:,.0f}원") return {"action": "SKIP", "reason": "min_amount_guard", "stock": stock_name} # ── 8. Redis 분산락 획득 ────────────────────────────────────────────────── # 여러 워커/프로세스가 동시에 같은 종목을 매수하는 것을 방지 if not await _acquire_order_lock(stock_code, "BUY"): return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} # ── 9. 주문 실행 (종목별 세마포어 + 재시도) ─────────────────────────────── # 세마포어: 단일 프로세스 내 동일 종목 동시 주문 방지 # 재시도: exponential backoff (1→2→4초, 최대 3회) try: order_price = _adjust_order_price(current_price, "BUY", is_domestic) # +0.5% 체결률 향상 async with _get_order_semaphore(stock_code): order_result = await _place_order_with_retry( is_domestic=is_domestic, stock_code=stock_code, order_type="BUY", quantity=quantity, price=order_price, exchange_code=exchange_code, ) total_amount = Decimal(str(order_price)) * quantity # ── 매수 스토리 구성: 기술 + 뉴스 + 실적 + 전망 (Phase 1: 기록 전용) ────── # 주문은 이미 위에서 접수됐으므로 여기서의 펀더멘털 조회는 주문을 지연·차단하지 # 않는다(실패/타임아웃 시 기술 스토리로 폴백). 매매 판단엔 미사용. _story = [f"기술: {', '.join(signals[:3])} (점수 {score_val})"] try: _ns = stock.get("_news_position_scale") if _ns is not None and float(_ns) < 1.0: _story.append(f"뉴스: 악재 감지 → 비중 {float(_ns):.0%}") except Exception: pass if is_domestic: try: from app.services.naver_finance import get_stock_fundamentals _fd = await get_stock_fundamentals(stock_code) if _fd: _val = [] if _fd.get("per"): _val.append(f"PER {_fd['per']}") if _fd.get("eps"): _val.append(f"EPS {_fd['eps']}") if _val: _story.append("실적: " + ", ".join(_val)) _tp = _fd.get("target_price") if _tp: try: _tpv = float(str(_tp).replace(",", "")) _up = (_tpv / current_price - 1) * 100 if current_price else 0.0 _outlook = f"전망: 목표가 {_tp}({_up:+.0f}%)" if _fd.get("recomm_mean"): _outlook += f", 투자의견 {_fd['recomm_mean']}/5" _story.append(_outlook) except Exception: pass except Exception as _story_err: logger.debug(f"[STORY] {stock_code} 펀더멘털 스킵: {_story_err}") entry_reason = " | ".join(_story) success = order_result.get("rt_cd") == "0" # KIS 성공 코드 is_pending = order_result.get("rt_cd") == "1" # 주문접수(체결 미확인) status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED") # TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수) kis_order_id = build_kis_order_id(order_result) from app.services.db_worker import enqueue_trade_history await enqueue_trade_history({ "rule_id": None, # JSON config rule은 trading_rules 테이블에 없으므로 FK 위반 방지 "stock_code": stock_code, "stock_name": stock_name, "order_type": "BUY", "order_price": Decimal(str(order_price)), "order_quantity": quantity, "executed_price": Decimal(str(order_price)) if success else None, "executed_quantity": quantity if success else 0, "total_amount": total_amount, "status": status, "order_id": kis_order_id, "message": f"자동매수: {'추가매수' if holding else '신규'} | {' | '.join(signals[:2])}", "entry_reason": entry_reason, "signals": signals, "strategy_type": stock.get("strategy_type", "swing"), "currency_code": get_market_info(stock_code)["currency"], "exchange_code": exchange_code, "created_at": _get_kst_now(), "executed_at": _get_kst_now() if success else None, }) if success: # [C2] ActiveTrade 생성 — 분할매도/트레일링 스탑 추적용 try: from app.services.db_worker import enqueue_active_trade_upsert await enqueue_active_trade_upsert({ "stock_code": stock_code, "stock_name": stock_name, "quantity": quantity, "entry_price": order_price, # 듀얼 전략(MOMENTUM_BREAKOUT/PULLBACK_BUY)은 strategy_name 으로 전달돼 # 청산 분기를 결정한다. 없으면 strategy_type 폴백. "strategy_name": stock.get("strategy_name") or stock.get("strategy_type", "swing"), "stop_loss": stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT)), "take_profit": stock.get("take_profit", 5.0), "entry_reason": entry_reason, "highest_since_entry": order_price, }) except Exception as _at_err: logger.warning(f"[BUY] ActiveTrade 생성 실패 {stock_code}: {_at_err}") # 인메모리 포트폴리오 캐시 즉시 반영 (UI 실시간 업데이트) try: from app.api.endpoints.portfolio import portfolio_cache await portfolio_cache.apply_buy(stock_code, stock_name, quantity, order_price) except Exception: pass # WebSocket → 프론트엔드 즉시 갱신 트리거 try: from app.services.websocket_manager import websocket_manager await websocket_manager.broadcast_json({ "type": "trade_executed", "data": { "stock_code": stock_code, "stock_name": stock_name, "trade_type": "BUY", "quantity": quantity, "price": current_price, }, }) except Exception: pass # Telegram 매수 체결 알림 try: from app.services.telegram_notifier import telegram_notifier asyncio.create_task(telegram_notifier.notify_trade_executed( order_type="BUY", stock_code=stock_code, stock_name=stock_name, quantity=quantity, price=current_price, )) except Exception: pass # 3초 후 KIS 체결 확인 → 슬리피지 기록 + 부분체결 재주문 (백그라운드) asyncio.create_task( _verify_and_sync_execution( order_id=kis_order_id, stock_code=stock_code, stock_name=stock_name, ordered_quantity=quantity, order_price=order_price, order_type="BUY", is_domestic=is_domestic, exchange_code=exchange_code, db_trade_id=0, # db_worker 큐 경유로 trade_id 미확보, order_id로 조회 delay=3, ) ) elif is_pending: # PENDING(주문접수) 상태이면 60초 후 미체결 자동 취소 시도 order_id_val = build_kis_order_id(order_result) if order_id_val: asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) return { "action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success, } except ValueError as e: logger.error(f"[BUY_VALIDATION] {stock_code}: {e}") return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} except Exception as e: logger.error(f"[BUY_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True) return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} finally: await _release_order_lock(stock_code, "BUY") async def _execute_sell( stock: dict, db: AsyncSession, holding, current_price: float, signals: list, is_domestic: bool, exchange_code: str, stock_code: str, stock_name: str, sell_pct: float = 1.0, # [CLAUDE.md 5.2] 분할매도 비율 (0.5 = 50%, 1.0 = 전량) ) -> dict: """SELL 주문 실행 로직 — 중복체크 → semaphore → 주문 → TradeHistory → 체결 확인. [동작 흐름] 1. 현재 손익률 계산 (현재가 vs 평균매입가) 2. ATR 기반 동적 손절선·트레일링스탑 재계산 3. Redis 분산락 획득 → 중복 매도 방지 4. 30초 이내 동일 종목 SELL 기록 확인 (FAILED 제외) 5. 지정가 -0.5% 조정 후 KIS SELL 주문 6. TradeHistory DB 기록 (flush → id 확보 → commit) 7. 성공 시: 포트폴리오 캐시 즉시 제거 + 체결 확인 백그라운드 태스크 [2026-03-15 CLAUDE.md 정합성 수정] - sell_pct 파라미터 추가: check_exit_signal의 SELL_PARTIAL(50%) 지원 - 기본 손절 -6% (CLAUDE.md 5.2), 익절 2R (ATR 기반) """ # [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정 # [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩 # 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다 # → 데이트레이딩 엔진이 들고 있는 수량은 제외한 '이 전략의 보유분'만 대상으로 한다. total_qty = holding.quantity # [M-3 2026-09-27] 변수 기준 구분: # _row_total_qty = Portfolio 행 수량(스윙+DT 합산) — 포트폴리오 캐시/DB 행 기준 # total_qty = 이 전략의 보유분(_own_qty, DT 제외) — 주문·잔량 회계 기준 # 캐시 반영은 반드시 행 합산 기준(_row_total_qty)을 써야 DT 보유분이 사라지지 않는다 # (swing/exit_service.reflect_sell / portfolio_cache_bridge 와 동일 기준). _row_total_qty = int(total_qty or 0) _strategy_tag = stock.get("strategy_type", "swing") if _strategy_tag != "daytrading": try: from app.services.daytrading.position_manager import day_trading_state as _dts _dt_pos = _dts.positions.get(stock_code) _dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0) except Exception: _dt_qty = 0 if _dt_qty > 0: _own = max(0, total_qty - _dt_qty) if _own <= 0: logger.info( f"[SELL] {stock_name}: 보유 {total_qty}주 전량이 데이트레이딩 포지션 — 스윙 청산 스킵" ) return {"action": "SKIP", "reason": "owned_by_daytrading", "stock": stock_name} logger.info( f"[SELL] {stock_name}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 → {_own}주 대상" ) total_qty = _own # = _own_qty (이 전략 보유분) # ── 매도 수량 결정 (원본 수량 기준) ────────────────────────────────────── # [M-1 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율. # check_exit_signal(>0.50) / _check_momentum_exit(>0.75, 0.45~0.75) / # range_trading_strategy(>0.50) 의 remaining_pct 게이트가 원본 누적 # (1.0 → 0.70 → 0.40)을 전제하므로 매도 수량도 원본 기준이어야 한다. # 기존 코드는 수량은 '현재 잔량' 기준, new_remaining 은 '원본' 기준 차감이라 # 분할이 반복될수록 과소매도 + remaining_pct 오염이 누적됐다. # → swing/exit_service.py 와 동일하게 원본 수량을 remaining_pct 로 역산한다. _rp_for_qty, _rp_tracked_for_qty = _resolve_remaining_pct_v2(stock, holding) _original_qty = total_qty if _rp_tracked_for_qty and 0.0 < _rp_for_qty < 1.0: # 0 나눗셈 가드 = 위 조건(0 < rp), original_qty < total_qty 가드 = max() _original_qty = max(total_qty, int(round(total_qty / _rp_for_qty))) elif not _rp_tracked_for_qty and sell_pct < 1.0: # 추적 불가(ActiveTrade active 행 없음/오염) → 역산 불가. # 상위 execute_auto_trade 가 이 경우 부분매도 신호를 폐기하므로(§8.5 항목4①) # 정상 경로에서는 도달하지 않는다. 방어적으로 '현재 잔량 = 원본' 으로 두어 # 기존 동작(현재 잔량 기준 비율 매도)을 유지한다. logger.warning( f"[SELL] {stock_code}: remaining_pct 추적 불가 상태에서 부분매도 요청 " f"(sell_pct={sell_pct*100:.0f}%) — 원본 수량 역산 불가, 현재 잔량 기준 적용" ) if sell_pct >= 1.0: quantity = total_qty # 전량매도 시 잔여분 없도록 (기존 동작 유지) else: quantity = min(max(int(_original_qty * sell_pct), 1), total_qty) # [M-2 2026-09-27] 계산된 매도 수량이 보유 전량과 같아지면(1~2주 소량 보유 + # sell_pct=0.5 → max(1,·) 로 전량 체결) 실질적 전량 청산이다. sell_pct<1.0 # 분기를 그대로 타면 close_active_trade 가 호출되지 않아 quantity=0 유령 active # 행이 남고, loss_tracker.record_trade_result_safe() 도 스킵돼 §5.1 3연패 밴 # 카운트에서 이 청산이 빠진다 → 실제 체결 기준으로 전량 청산 처리한다. _full_exit = sell_pct >= 1.0 or (total_qty > 0 and quantity >= total_qty) if _full_exit and sell_pct < 1.0: logger.info( f"[SELL] {stock_code}: 부분매도 요청({sell_pct*100:.0f}%)이 보유 전량" f"({total_qty}주)과 동일 → 전량 청산으로 처리 (ActiveTrade 종료 + 손실추적)" ) _avg = float(holding.average_price or 0) profit_loss_rate = calculate_profit_loss_rate(current_price, _avg, holding.profit_loss_rate) # [CLAUDE.md 5.2] 기본 손절 -6% (안정적 종목), 익절은 ATR 기반 2R take_profit = stock.get("take_profit", 5.0) base_stop_loss = stock.get("stop_loss", -float(settings.STOP_LOSS_PERCENT)) # ── ATR 기반 동적 손절/트레일링 스탑 조정 ──────────────────────────────── atr = float(stock.get("atr", 0) or 0) atr_stops = calculate_atr_based_stops( _avg, current_price, atr, stock, base_stop_loss=base_stop_loss ) # [2026-09-27] atr_stops["stop_loss"] 는 _execute_sell 내에서 사용되지 않아 제거 # (실제 손절 판정은 execute_auto_trade의 ATR 강제 손절/check_exit_signal에서 수행) trailing_stop_price = atr_stops["trailing_stop_price"] exit_reason_detail = atr_stops["exit_reason_detail"] or f"손익률 {profit_loss_rate:.2f}%" # Redis 분산락 획득 — 여러 워커가 동시에 같은 종목을 매도하는 것을 방지 if not await _acquire_order_lock(stock_code, "SELL"): return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} try: # ── 1. 중복 주문 체크 (30초 이내, FAILED 제외) ─────────────────────── # FAILED 주문은 제외하여 오류 이후 재매도 허용 recent_cutoff = _get_kst_now() - timedelta(seconds=30) duplicate_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", TradeHistory.status.in_(["COMPLETED", "PARTIAL", "PENDING"]), TradeHistory.created_at >= recent_cutoff, ) ) if duplicate_check.scalars().first(): return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} # ── 2. 주문 실행 (종목별 세마포어 + 재시도) ────────────────────────── order_price = _adjust_order_price(current_price, "SELL", is_domestic) # -0.5% 체결률 향상 async with _get_order_semaphore(stock_code): order_result = await _place_order_with_retry( is_domestic=is_domestic, stock_code=stock_code, order_type="SELL", quantity=quantity, price=order_price, exchange_code=exchange_code, ) total_amount = Decimal(str(current_price)) * quantity gross_profit_loss = (current_price - float(holding.average_price or current_price)) * quantity # [#4 수수료/세금 반영] net PnL 계산 _market_type = stock.get("market", "KOSPI") or "KOSPI" trade_costs = calculate_trade_costs( entry_price=_avg, exit_price=current_price, quantity=quantity, is_domestic=is_domestic, market=_market_type, ) profit_loss = gross_profit_loss - trade_costs["total_cost"] # net 손익 success = order_result.get("rt_cd") == "0" is_pending = order_result.get("rt_cd") == "1" status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED") # TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수) kis_order_id = build_kis_order_id(order_result) from app.services.db_worker import enqueue_trade_history await enqueue_trade_history({ "rule_id": None, "stock_code": stock_code, "stock_name": stock_name, "order_type": "SELL", "order_price": Decimal(str(current_price)), "order_quantity": quantity, "executed_price": Decimal(str(current_price)) if success else None, "executed_quantity": quantity if success else 0, "total_amount": total_amount, "status": status, "order_id": kis_order_id, "message": f"자동매도: {exit_reason_detail}", "exit_reason": f"손익: {profit_loss:,.0f}원 (수수료/세금: {trade_costs['total_cost']:,.0f}원)", "signals": signals, "commission": Decimal(str(trade_costs["total_cost"])) if success else None, "net_profit_loss": Decimal(str(profit_loss)) if success else None, "profit_loss_rate": Decimal(str(profit_loss_rate)) if success else None, "strategy_type": stock.get("strategy_type", "swing"), "currency_code": get_market_info(stock_code)["currency"], "exchange_code": exchange_code, "created_at": _get_kst_now(), "executed_at": _get_kst_now() if success else None, }) if success: # [CLAUDE.md 5.5 / 8.1#6] 연속 손실 추적 — 전량매도 시 loss_tracker 기록 # [M-2] _full_exit = sell_pct>=1.0 또는 '계산 수량 == 보유 전량' if _full_exit: try: from app.services.loss_tracker import loss_tracker is_loss = profit_loss_rate < 0 await loss_tracker.record_trade_result_safe( stock_code=stock_code, is_loss=is_loss, ) await loss_tracker.save_to_db(stock_code=stock_code) except Exception as _lt_err: logger.warning(f"[SELL] loss_tracker 업데이트 실패 {stock_code}: {_lt_err}") # [C2] ActiveTrade 종료/갱신 try: # 실제 행의 strategy_name 을 사용한다. strategy_type("swing")으로 닫으면 # 듀얼 전략 행(MOMENTUM_BREAKOUT/PULLBACK_BUY)과 매칭되지 않아 유령 행이 남는다. _at_name = stock.get("strategy_name") # [§8.5 항목3 2026-09-27] 역추적 쿼리에 DT 제외 조건이 없어, 같은 종목에 # DT 행이 병존하면 스윙 매도가 DT 행의 strategy_name 을 집어 DT # ActiveTrade 를 닫아버렸다 → not_dt_strategy_clause 로 통일. # 전략명이 NULL 인 비-DT 행(legacy/동기화 유입)은 이름으로 매칭할 수 # 없으므로 payload 에 strategy_name_not 필터를 써서 그 행을 직접 겨냥한다 # ('swing' 으로 폴백하면 NULL 행과 매칭되지 않아 유령 active 행이 남는다). _at_filter_not_dt = False if not _at_name: try: from app.models.models import ActiveTrade as _AT from app.services.db_worker import not_dt_strategy_clause _at_row = (await db.execute( select(_AT) .where( _AT.stock_code == stock_code, _AT.status == "active", not_dt_strategy_clause(_AT.strategy_name), ) .order_by(_AT.id.desc()) .limit(1) )).scalars().first() _at_name = _at_row.strategy_name if _at_row else None if not _at_name: # 비-DT 행이 없거나(=닫을 대상 없음) 전략명이 NULL 인 경우. # DT 로 태깅된 보유분은 기존 동작(=DT 행 종료)을 유지한다. from app.services.db_worker import DT_STRATEGY_NAMES _st = stock.get("strategy_type") or "swing" if _st in DT_STRATEGY_NAMES: _at_name = _st else: _at_filter_not_dt = True except Exception: _at_name = None _at_filter_not_dt = False _at_name = _at_name or stock.get("strategy_type", "swing") if _full_exit: # [M-2] 실제 체결이 보유 전량이면 active 행을 닫는다 if _at_filter_not_dt: from app.services.db_worker import ( DT_STRATEGY_NAMES as _DTN, enqueue_db_write, ) await enqueue_db_write({ "type": "close_active_trade", "data": { "stock_code": stock_code, "strategy_name_not": list(_DTN), }, }) else: from app.services.db_worker import enqueue_close_active_trade await enqueue_close_active_trade(stock_code, strategy_name=_at_name) else: from app.services.db_worker import enqueue_active_trade_upsert # [§8.5 항목4] 추적 여부 확인용 (미추적 시 기존과 동일하게 1.0). # 잔량 비율 자체는 아래에서 '원본 수량 기준 실제 체결' 로 계산한다. remaining_pct, _rp_tracked = _resolve_remaining_pct_v2(stock, holding) if not _rp_tracked: logger.info( f"[SELL] {stock_code}: remaining_pct 추적 불가 → 1.0 기준으로 " f"ActiveTrade 잔량 재설정 (sell_pct={sell_pct*100:.0f}%)" ) # [M-1] 원본 수량 기준 실제 잔량 비율. 뺄셈(remaining_pct - sell_pct)은 # 매도 수량이 현재 잔량 기준일 때와 어긋나 잔량이 오염됐다 → # 실제 체결 수량으로 계산한다 (예: 원본 100, 잔량 70, 50주 매도 → 0.20). new_remaining = max(0.0, (total_qty - quantity) / _original_qty) \ if _original_qty > 0 else 0.0 _at_payload = { "stock_code": stock_code, "remaining_pct": new_remaining, "quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지) "highest_since_entry": current_price, } if _at_filter_not_dt: # 전략명 NULL 인 비-DT 행 대상 (이름 동등 매칭 불가). # 대상 행이 아예 없으면 db_worker 가 필수 키 누락으로 스킵 — # 매도 경로에서 새 active 행을 만들지 않는 것이 의도된 동작이다. from app.services.db_worker import DT_STRATEGY_NAMES as _DTN2 _at_payload["strategy_name_not"] = list(_DTN2) else: _at_payload["strategy_name"] = _at_name await enqueue_active_trade_upsert(_at_payload) except Exception as _at_err: logger.warning(f"[SELL] ActiveTrade 업데이트 실패 {stock_code}: {_at_err}") # 인메모리 포트폴리오 캐시 반영 (UI 실시간 업데이트) try: from app.api.endpoints.portfolio import portfolio_cache avg = float(holding.average_price or current_price) await portfolio_cache.apply_sell(stock_code, quantity, current_price, avg) # [CLAUDE.md 5.2] 분할매도: 잔여 수량이 있으면 캐시에서 제거하지 않음 # [M-3 2026-09-27] 캐시/Portfolio 행 기준 잔량은 '행 합산(_row_total_qty)' # 에서 매도 수량을 뺀 값이어야 한다. 스윙 보유분(total_qty=_own_qty)으로 # 계산하면 스윙 전량 청산 시 remaining_qty=0 이 되어 DT 보유분까지 캐시에서 # 삭제됐다 (Portfolio DB 행은 합산 수량으로 잔존 → 캐시/DB 불일치). remaining_qty = max(0, _row_total_qty - quantity) if remaining_qty <= 0: portfolio_cache.holdings = [ h for h in portfolio_cache.holdings if h.stock_code != stock_code ] else: # 잔여 수량 반영 (분할매도 후 남은 포지션) for h in portfolio_cache.holdings: if h.stock_code == stock_code: h.quantity = remaining_qty break except Exception: pass # WebSocket → 프론트엔드 즉시 갱신 트리거 try: from app.services.websocket_manager import websocket_manager await websocket_manager.broadcast_json({ "type": "trade_executed", "data": { "stock_code": stock_code, "stock_name": stock_name, "trade_type": "SELL", "quantity": quantity, "price": current_price, }, }) except Exception: pass # Telegram 매도 체결 알림 try: from app.services.telegram_notifier import telegram_notifier asyncio.create_task(telegram_notifier.notify_trade_executed( order_type="SELL", stock_code=stock_code, stock_name=stock_name, quantity=quantity, price=current_price, profit_loss=profit_loss, profit_loss_rate=profit_loss_rate, )) except Exception: pass # 3초 후 KIS 체결 확인 (슬리피지 기록 포함) asyncio.create_task( _verify_and_sync_execution( order_id=kis_order_id, stock_code=stock_code, stock_name=stock_name, ordered_quantity=quantity, order_price=order_price, order_type="SELL", is_domestic=is_domestic, exchange_code=exchange_code, db_trade_id=0, # db_worker 큐 경유로 trade_id 미확보, order_id로 조회 delay=3, ) ) elif is_pending: # PENDING 상태 → 60초 후 미체결 자동 취소 시도 order_id_val = build_kis_order_id(order_result) if order_id_val: asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) return { "action": "SELL", "stock": stock_name, "quantity": quantity, "price": current_price, "profit_loss": profit_loss_rate, "success": success, } except ValueError as e: logger.error(f"[SELL_VALIDATION] {stock_code}: {e}") return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} except Exception as e: logger.error(f"[SELL_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True) return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} finally: await _release_order_lock(stock_code, "SELL") # execute_auto_trade_background 동시 실행 방지 — 스윙 스케줄러·API 트리거가 겹치면 # 일일 거래 한도(MAX_DAILY_TRADES)가 호출 단위로만 적용돼 합산 초과됨 (2026-09-17 11종목 동시 매수 사고). _auto_trade_bg_lock = asyncio.Lock() async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): """recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행. [동작 흐름] 1. 시장 국면(BEAR_MARKET_FILTER) 조회 → 최대 거래 수·포지션 배율 결정 2. recommendations_by_rule 그룹을 단일 unique_stocks dict로 정규화 - 동일 종목이 여러 룰에 걸리면 우선순위(rule_priority) 낮은 쪽 유지 3. (rule_priority ASC, score DESC)로 정렬 → 상위 max_trades개 처리 4. 종목별 execute_auto_trade() 호출 (0.5초 간격 → KIS API 레이트 리밋 준수) """ if not isinstance(recommendations_response, dict): logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response) return [] if _auto_trade_bg_lock.locked(): logger.info("[AUTO_TRADE_BG] 이전 실행이 진행 중 — 이번 호출 스킵 (중복 실행 방지)") return [] async with _auto_trade_bg_lock: return await _execute_auto_trade_background_locked(recommendations_response, db) async def _execute_auto_trade_background_locked(recommendations_response: dict, db: AsyncSession): # trade_results 는 except 절에서도 참조하므로 try 밖에서 바인딩한다(항상 list 반환 보장). trade_results: list = [] try: total_processed = 0 # 시장 국면 조회 (BEAR 시 max_trades 축소, position_multiplier 감소) market_regime = None if settings.BEAR_MARKET_FILTER_ENABLED: market_regime = await check_market_regime() regime_max_trades = market_regime.get("max_trades", 20) if market_regime else 20 position_multiplier = market_regime.get("position_multiplier", 1.0) if market_regime else 1.0 max_trades = min(settings.MAX_DAILY_TRADES, regime_max_trades) # 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용 # (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨) try: _today_buys = await db.execute( select(TradeHistory.stock_code) .where( TradeHistory.order_type == "BUY", TradeHistory.status.in_(["COMPLETED", "PENDING"]), TradeHistory.created_at >= get_daily_cutoff_utc(), ) .distinct() ) _bought_today = len(_today_buys.scalars().all()) except Exception as _e: logger.warning(f"[AUTO_TRADE_BG] 당일 매수 건수 조회 실패 (한도 미차감): {_e}") _bought_today = 0 max_trades = max(0, max_trades - _bought_today) if max_trades == 0: logger.info( f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 " f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵" ) return [] # [2026-09-27] held_stocks 집합은 파일 전체에서 미사용이라 전용 SELECT 와 함께 제거. # 보유 여부는 execute_auto_trade() 내부에서 종목별로 직접 조회한다. # recommendations_by_rule 정규화 (list 또는 dict 모두 처리) rec_groups = recommendations_response.get("recommendations_by_rule", []) if isinstance(rec_groups, dict): normalized_list = [] for name, group in rec_groups.items(): if isinstance(group, dict): normalized_list.append(group) else: normalized_list.append({"rule_name": name, "stocks": group}) rec_groups = normalized_list # 종목 코드 기준으로 중복 제거 (rule_priority 낮은 쪽 = 더 중요한 규칙 우선) unique_stocks: dict = {} for rule_group in rec_groups: if not isinstance(rule_group, dict): logger.warning("[AUTO_TRADE_BG] Invalid rule_group: %r", rule_group) continue stocks_list = rule_group.get("stocks") if not isinstance(stocks_list, list): logger.warning("[AUTO_TRADE_BG] Invalid stocks list: %r", stocks_list) continue for stock in stocks_list: if not isinstance(stock, dict): logger.warning("[AUTO_TRADE_BG] Skipping non-dict stock entry: %r", stock) continue stock_code = stock.get("stock_code") if stock_code not in unique_stocks: unique_stocks[stock_code] = stock elif stock.get("rule_priority", 999) < unique_stocks[stock_code].get("rule_priority", 999): unique_stocks[stock_code] = stock # 더 높은 우선순위 룰로 교체 # 우선순위↑(숫자↓), 점수↓ 순으로 정렬 후 max_trades개만 처리 sorted_stocks = sorted( unique_stocks.values(), key=lambda s: (s.get("rule_priority", 999), -s.get("score", 0)), ) for stock in sorted_stocks[:max_trades]: if not isinstance(stock, dict): logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock) continue try: stock["_position_multiplier"] = position_multiplier # 국면별 배율 주입 stock["_regime_str"] = ( market_regime.get("regime_200d", "bear") if market_regime else "bear" ) result = await execute_auto_trade(stock, db) total_processed += 1 if result["action"] in ["BUY", "SELL"]: trade_results.append(result) else: # [관측성] 매수 후보가 왜 진입 안 됐는지 사유를 남긴다. 기존엔 HOLD/SKIP # 사유가 로그에 안 남아 "매수 실행" 후 조용히 스킵되는 이유를 추적하기 # 어려웠다(예: 플레이북 FOMC 차단, 상관/뉴스/쿨다운, 수량 0 등). logger.info( f"[AUTO_TRADE_BG] {stock.get('stock_code')} 진입 보류: " f"action={result.get('action')} reason={result.get('reason', '미상')}" ) await asyncio.sleep(0.5) # KIS API 레이트 리밋: 초당 2회 이하 권장 except Exception as e: logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {e}") continue # [관측성 2026-09-27] 체결 결과를 호출부로 반환한다. 기존엔 trade_results 를 # 만들어놓고 return 이 없어 항상 None 이 나갔고, swing/entry_service.py 의 # isinstance(result, list) 분기가 영구 False → "진입 n건" 로그가 사라졌다. # 담기는 값은 action 이 BUY/SELL 인 dict 뿐이다(SKIP/HOLD/*_FAILED 는 제외). return trade_results except Exception as e: logger.error( f"[AUTO_TRADE_BG] 전체 오류: {type(e).__name__}: {e} — " f"recommendations_response keys={list(recommendations_response.keys()) if isinstance(recommendations_response, dict) else '?'}" ) # 예외 시점까지 실제로 체결된 건은 유실 없이 보고한다(부분 결과 반환). return trade_results async def _background_sync_portfolio(): try: from app.api.endpoints.portfolio import sync_portfolio async with AsyncSessionLocal() as session: await sync_portfolio(session, force=True) except Exception as e: logger.error(f"[PORTFOLIO_SYNC] 백그라운드 동기화 실패: {type(e).__name__}: {e}") async def _delayed_sync_portfolio(delay_seconds: int = 3): try: await asyncio.sleep(delay_seconds) from app.api.endpoints.portfolio import sync_portfolio async with AsyncSessionLocal() as session: try: await sync_portfolio(session, force=True) except Exception as inner_e: logger.error(f"[PORTFOLIO_SYNC] 실행 실패: {type(inner_e).__name__}: {inner_e}") except Exception as e: logger.error(f"[PORTFOLIO_SYNC] 딜레이 동기화 실패: {type(e).__name__}: {e}") async def execute_auto_trade(stock: dict, db: AsyncSession): """종목 단건 매매 판단 및 실행. 공통 필터(가격·MDD·손실추적·보유수·당일매도쿨다운·신호분석)를 통과한 후 _execute_buy / _execute_sell 에 위임한다. [필터 체크 순서] 1. 유효성 검사 (stock_code, current_price) 2. 국내/해외 구분 (stock_code 6자리 숫자 → 국내) 3. 가격 필터 (국내 5,000원↑ / 미국 $4↑) 4. MDD 일시정지 여부 (MDD < -10% 시 신규진입 중단) 5. 연속손실 밴 여부 (3회 연속 손실 → 30일 진입 금지) 6. 최대 보유 종목 수 초과 여부 (settings.MAX_HOLDINGS_COUNT) 7. 당일 매도 후 재매수 쿨다운 (당일 SELL COMPLETED 기록 존재 시 재매수 금지) 8. 신호 분석: BUY_KEYWORDS / SELL_KEYWORDS / BLOCK_KEYWORDS 매칭 9. ML 가중치 통합 (ML_ENSEMBLE_ENABLED 시 rule 40% + ML 60% 합산) 10. ATR 강제 손절 (신호 없어도 현재가 ≤ avg - 2.5×ATR 이면 강제 매도) 11. _execute_buy 또는 _execute_sell 위임 """ try: stock_code = stock.get("stock_code") stock_name = stock.get("stock_name") current_price = float(stock.get("current_price", 0)) signals = stock.get("signals", []) if not stock_code or current_price <= 0: return {"action": "SKIP", "reason": "invalid_data"} # ── 비상 정지 (TRADING_HALTED) 가드 ───────────────────────────── if settings.TRADING_HALTED: return {"action": "SKIP", "reason": "trading_halted", "stock": stock_name} # 국내(6자리 숫자) vs 해외 판별 — exchange_detector 모듈 사용 is_domestic = not is_us_stock(stock_code) if is_domestic: exchange_code = "KRX" else: exchange_code = stock.get("exchange_code") or detect_exchange(stock_code) # ── 가격 필터 (CLAUDE.md 5.4: ETF/리츠 예외) ───────────────────────── _ETF_KEYWORDS = ( "ETF", "KODEX", "TIGER", "KBSTAR", "ARIRANG", "SOL", "HANARO", "ACE", "리츠", "REIT", "인프라", ) _is_etf = stock_name and any(kw in (stock_name or "").upper() for kw in _ETF_KEYWORDS) if is_domestic: if current_price <= settings.MIN_PRICE_KRW and not _is_etf: # CLAUDE.md 5.4: "초과" return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name} else: if current_price <= settings.MIN_PRICE_USD and not _is_etf: # CLAUDE.md 5.4: "초과" return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name} # ── 현재 보유 조회 (리스크 가드보다 먼저) ──────────────────────────── # [2026-09-27 M3] MDD/밴/보유수/쿨다운 가드는 '신규 매수'에만 적용해야 한다. # 기존에는 즉시 SKIP 반환해 보유 종목의 check_exit_signal·ATR 강제 손절에 # 도달조차 못했다(MDD -10%·보유 20종목 = 가장 청산이 필요한 국면에서 손절 불가). # → 보유가 있으면 사유를 _buy_blocked_reason 에 담고 청산 판정까지 진행한다. result = await db.execute( select(Portfolio).where( Portfolio.stock_code == stock_code, Portfolio.quantity > 0, ) ) holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단 _buy_blocked_reason: str | None = None # [2026-09-27 H-1] 당일 SELL 체결 여부. 보유 종목의 청산 판정은 계속 진행하되, # '부분매도'가 사이클마다 반복되는 것(계단식 청산 + 왕복 수수료)을 막기 위한 플래그. _sold_today = False def _guard(reason: str): """보유 없으면 즉시 SKIP dict 반환, 보유 중이면 매수만 차단하고 계속 진행.""" nonlocal _buy_blocked_reason if holding is None: return {"action": "SKIP", "reason": reason, "stock": stock_name} if _buy_blocked_reason is None: _buy_blocked_reason = reason return None # ── MDD / 손실추적 / 보유수 ────────────────────────────────────────── if not await mdd_manager.can_open_new_position(): # MDD -10% 이상 낙폭 → 신규 진입 중단 _r = _guard("mdd_pause") if _r is not None: return _r if await loss_tracker.is_banned_safe(stock_code): # 3회 연속 손실 종목 → 30거래일 진입 금지 _r = _guard("consecutive_loss_ban") if _r is not None: return _r held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) # 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback) _adaptive_max_holdings = settings.MAX_HOLDINGS_COUNT try: from app.services.adaptive_strategy import get_current_adaptive_params _adaptive = get_current_adaptive_params() if not _adaptive.fallback_used: _adaptive_max_holdings = _adaptive.max_holdings except Exception: pass if len(held_result.scalars().all()) >= _adaptive_max_holdings: # 동시 보유 한도 초과 (적응형: max_holdings) _r = _guard("max_holdings_reached") if _r is not None: return _r # ── 당일 매도 후 재매수 쿨다운 ─────────────────────────────────────── # 당일 이미 SELL 체결된 종목은 당일 재매수 금지 (섹션 4.3) # [2026-09-27 §8.5 항목5] PENDING(접수만 되고 미체결) SELL 주문을 포함한다. # 근거: models.py:141 status(String), :157 created_at(DateTime, default=now_kst), # :158 executed_at(DateTime, nullable) — 둘 다 KST naive 저장. # _execute_sell(trade_executor.py:1303·1329-1330)은 rt_cd=="1" 일 때 # status="PENDING", executed_at=None 으로 기록하므로 executed_at 날짜 # 필터만으로는 PENDING 행이 전부 탈락한다 → 지정가 부분매도가 미체결인 # 동안 _sold_today=False 로 남아 사이클마다 중복 부분매도가 쌓였다. # PENDING 은 created_at 으로 '당일'만 인정 → 체결·취소 없이 남은 과거 유령 행이 # 쿨다운을 영구히 걸지 않는다. cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) recent_sell = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", or_( and_( TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), TradeHistory.executed_at >= cutoff_time, ), and_( TradeHistory.status == "PENDING", TradeHistory.created_at >= cutoff_time, ), ), ) ) if recent_sell.first(): _sold_today = True _r = _guard("cooldown_sell_today") if _r is not None: return _r # [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도 # remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지. # 읽기 전용 SELECT만 수행 (DB 쓰기 없음, swing/exit_service.py의 자체 추적과 무충돌). if holding is not None and stock.get("remaining_pct") is None: try: from app.models.models import ActiveTrade # [2026-09-27 M6] swing/position_manager.get_swing_active_trade 및 # db_worker 와 동일 패턴: day_trade 행 제외 + 최신 행 우선. # (같은 종목에 swing/day_trade 행이 동시에 active 일 때 DT 행의 # remaining_pct 가 스윙 청산 판단에 주입되는 것을 방지) # [2026-09-27 §8.5 항목3] `!= "day_trade"` 는 SQL 3값 논리로 # strategy_name IS NULL 인 legacy 스윙 행까지 탈락시켜, 잔량 추적 # 소스를 잃고 remaining_pct 가 매 사이클 1.0 으로 뭉개졌다. # → db_worker.not_dt_strategy_clause() 로 3곳을 동일 표현으로 통일. from app.services.db_worker import not_dt_strategy_clause _at_result = await db.execute( select(ActiveTrade) .where( ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", not_dt_strategy_clause(ActiveTrade.strategy_name), ) .order_by(ActiveTrade.id.desc()) .limit(1) ) _active_trade = _at_result.scalars().first() if _active_trade is not None and _active_trade.remaining_pct is not None: stock["remaining_pct"] = float(_active_trade.remaining_pct) except Exception as _at_err: # 조회 실패 = 잔량 추적 불가 → _resolve_remaining_pct_v2 가 부분매도를 # 차단한다. 조용히 넘기면 원인 추적이 불가하므로 최소 warning. logger.warning( f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패 " f"(부분매도 차단, 전량 청산만 허용): {_at_err}" ) # ── 신호 분석 (Enum 기반) ───────────────────────────────────────────── text_signals = " ".join(signals) try: score_val = float(stock.get("score", 0) or 0) except Exception: score_val = 0.0 # "거래량부족" 신호 제거 후 매수 판단 (거래량 부족 단독으로는 매수 차단) signals_without_volume_weak = [s for s in signals if "거래량부족" not in s] text_signals_filtered = " ".join(signals_without_volume_weak) should_buy = any(k in text_signals_filtered for k in BUY_KEYWORDS) if not signals_without_volume_weak and signals: should_buy = False # 거래량부족 신호만 있으면 매수 금지 should_sell = any(k in text_signals for k in SELL_KEYWORDS) # ── ML 가중치 통합 (기본: 규칙 30% + ML 70%) ───────────────────────── # weighted_score = score × RULE_WEIGHT + ml_score × ML_WEIGHT # ML BUY: weighted_score ≥ threshold AND confidence ≥ min → should_buy = True # ML SELL: confidence ≥ 0.5 → should_sell = True # ML HOLD: weighted_score 낮으면 should_buy = False 강제 ml_score_trade = stock.get("ml_score") ml_confidence_trade = stock.get("ml_confidence") ml_signal_trade = stock.get("ml_signal") if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None: try: weighted_score = ( score_val * settings.ML_ENSEMBLE_RULE_WEIGHT + float(ml_score_trade) * settings.ML_ENSEMBLE_ML_WEIGHT ) if ( ml_signal_trade == "BUY" and weighted_score >= settings.ML_ENSEMBLE_BUY_THRESHOLD and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN ): should_buy = True elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5: should_sell = True elif ( ml_signal_trade == "HOLD" and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7 ): should_buy = False # ML이 HOLD 강하게 권고 시 매수 억제 except Exception as e: logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}") has_rule = stock.get("rule_id") is not None # ── BLOCK 키워드(과열·이격) 판정 ───────────────────────────────────── # [2026-09-27 H1-A] 기존에는 BLOCK 감지 시 즉시 HOLD를 return 해 # 아래 청산 판정(check_exit_signal·ATR 강제 손절)에 도달하지 못했다. # 보유 종목이 과열이면 오히려 매도해야 하는 국면인데 손절·전량익절·트레일링이 # 전부 스킵됐다. → 조기 return 대신 should_buy 만 끄고 청산 판정까지 진행한다. # 판정 임계값·BLOCK_KEYWORDS 목록은 무변경(제어 흐름만 수정). _block_overheat_reason: str | None = None if any(b in text_signals for b in BLOCK_KEYWORDS): _block_overheat_reason = ( "disparity_overheated" if has_rule else "overheat_no_rule" ) should_buy = False if holding is None: # 보유 없는 종목은 청산 판정이 무의미 → 기존과 동일하게 즉시 HOLD 반환 return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason} if has_rule and _block_overheat_reason is None: # 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호) # ML HOLD는 신뢰도 60% 이상일 때만 rule 매수를 차단 # (신뢰도 40% 미만의 약한 HOLD는 rule 판단을 우선) ml_hold_active = ( settings.ML_ENSEMBLE_ENABLED and ml_signal_trade == "HOLD" and not should_buy and (ml_confidence_trade or 0) >= 0.6 ) if not ml_hold_active: should_buy = True # ML HOLD이면 매수 억제 — 단, 신뢰도 60% 이상의 '확신 있는' HOLD만 차단. # [FIX] 기존에는 신뢰도 무관하게 차단해, 일봉 데이터 부족으로 ML 피처 추출이 # 실패한 경우(signal=HOLD, confidence=0 = 사실상 "의견 없음")까지 모든 매수를 # 막아버렸다(수개월간 거래 0의 근본 원인). 위 ml_hold_active(1417~) 로직과 동일하게 # confidence>=0.6 조건을 적용해, 저신뢰/실패 HOLD는 룰 기반 판단을 막지 않도록 한다. if ( ml_signal_trade == "HOLD" and settings.ML_ENSEMBLE_ENABLED and (ml_confidence_trade or 0) >= 0.6 ): should_buy = False logger.info(f"[TRADE] {stock_code}: ML HOLD(conf≥0.6) → 매수 차단") if should_buy and not has_rule: # 비규칙 종목: 강한 신호 OR (신호 2개 이상 AND 점수 4점 이상) 필수 has_strong_signal = any(k in text_signals_filtered for k in STRONG_BUY_KEYWORDS) matched_count = sum(1 for k in BUY_KEYWORDS if k in text_signals_filtered) if not has_strong_signal and (matched_count < 2 or score_val < 4): should_buy = False # ── CLAUDE.md 6.3: check_exit_signal 연동 (보유 종목 청산 판단) ───── # [2026-03-15] 기존에 텍스트 신호 매칭에만 의존하던 매도 판단을 # mean_reversion_strategy.check_exit_signal()로 보완. # RSI/BB/ATR 기반 부분매도(50%)/전량매도/트레일링스탑 규칙 적용. _exit_signal = None _exit_sell_pct = 1.0 if holding and not should_sell: avg_price = float(holding.average_price or 0) atr = float(stock.get("atr", 0) or 0) if avg_price > 0: # 차트 데이터가 있으면 RSI/BB 기반 청산 판단 closes = stock.get("closes", []) rsi_val = stock.get("rsi") bb_data = stock.get("bb") # (upper, middle, lower) # RSI/BB가 직접 전달되지 않은 경우 closes에서 계산 if rsi_val is None and len(closes) >= 15: rsi_val = calculate_rsi(closes, 14) if bb_data is None and len(closes) >= 20: bb_data = calculate_bollinger(closes, 20) if rsi_val is not None and bb_data is not None and atr > 0: bb_upper, bb_middle, bb_lower = bb_data if isinstance(bb_data, (tuple, list)) else (0, 0, 0) # remaining_pct: 현재 보유 비율 (1차 익절 전 = 1.0, 후 = 0.5 등) # [§8.5 항목4] None = '추적 소스 없음' 을 명시 상태로 분리. remaining_pct, _remaining_tracked = _resolve_remaining_pct_v2(stock, holding) trailing_high = float(stock.get("high_price") or current_price) lowest_since = float(stock.get("lowest_since_entry") or avg_price) _exit_signal = check_exit_signal( regime=MarketRegime.BULL if stock.get("_regime_str", "bear") == "bull" else MarketRegime.BEAR, rsi=rsi_val, close=current_price, bb_upper=bb_upper, bb_middle=bb_middle, bb_lower=bb_lower, entry_price=avg_price, atr=atr, lowest_since_entry=lowest_since, remaining_pct=remaining_pct, trailing_high=trailing_high, ) # [§8.5 항목4①] 잔량 추적 불가(ActiveTrade 행 없음/오염) 상태에서는 # 부분매도를 채택하지 않는다 — 매 사이클 remaining_pct=1.0 으로 # 재평가되어 계단식 분할매도 + 왕복 수수료가 무한 반복된다. # 전량 청산(손절·RSI 전량·트레일링)은 그대로 허용한다. if ( _exit_signal is not None and _exit_signal.sell_pct < 1.0 and not _remaining_tracked ): logger.info( f"[EXIT_SIGNAL] {stock_code}/{stock_name} 부분매도 차단: " f"remaining_pct 추적 불가(ActiveTrade active 행 없음 — " f"브로커 잔고 sync/legacy 보유분) " f"sell_pct={_exit_signal.sell_pct*100:.0f}%, " f"reason={_exit_signal.reason} — 전량 청산 신호만 허용" ) _exit_signal = None if _exit_signal: should_sell = True _exit_sell_pct = _exit_signal.sell_pct signals = list(signals) + [f"전략청산: {_exit_signal.reason}"] logger.info( f"[EXIT_SIGNAL] {stock_code}/{stock_name}: " f"{_exit_signal.action} {_exit_signal.sell_pct*100:.0f}% — {_exit_signal.reason}" ) # ATR 기반 강제 손절 (check_exit_signal에서 미처리된 경우 폴백) if not should_sell and atr > 0: atr_stop_price = avg_price - 2.5 * atr if current_price <= atr_stop_price: should_sell = True _exit_sell_pct = 1.0 # 손절은 전량 signals = list(signals) + [f"ATR 손절: {current_price:.0f} ≤ {atr_stop_price:.0f}"] # ── 뉴스 감성 필터 (매수 전) ─────────────────────────────────────── # NEWS_ENABLED 시 종목 뉴스 점수 조회 → 강한 악재 차단 / 약한 악재 포지션 축소 _news_position_scale = 1.0 # 뉴스에 의한 포지션 배율 (기본 100%) # [2026-09-27 H1] 청산 대상(보유+매도신호)은 뉴스 필터를 타지 않는다 — # news_negative SKIP 이 손절보다 먼저 반환되면 청산이 또 막힌다. if should_buy and not (should_sell and holding) and settings.NEWS_ENABLED: try: from app.services.news_sentiment import get_stock_news_score news_score = await get_stock_news_score(stock_code) if news_score <= settings.NEWS_BLOCK_SCORE: logger.info( f"[NEWS_FILTER] {stock_code}/{stock_name} 진입 차단: " f"news_score={news_score:.3f} <= {settings.NEWS_BLOCK_SCORE}" ) return {"action": "SKIP", "reason": "news_negative", "stock": stock_name, "news_score": news_score} elif news_score <= settings.NEWS_REDUCE_SCORE: _news_position_scale = 0.5 # 포지션 50% 축소 logger.info( f"[NEWS_FILTER] {stock_code}/{stock_name} 포지션 50% 축소: " f"news_score={news_score:.3f} <= {settings.NEWS_REDUCE_SCORE}" ) except Exception as e: logger.debug(f"[NEWS_FILTER] {stock_code} 뉴스 스코어 조회 오류 (무시): {e}") # 뉴스 포지션 배율을 stock dict에 전달 (_execute_buy에서 참조) if _news_position_scale < 1.0: stock["_news_position_scale"] = _news_position_scale # ── 매매 실행 위임 ──────────────────────────────────────────────────── # [2026-09-27 H1] 청산은 항상 매수보다 우선한다. # 기존에는 should_buy 를 먼저 평가해, 보유 종목이 당일 룰 추천에 포함되면 # (rule 기반은 BLOCK/ML HOLD 만 없으면 should_buy=True 강제) # 손절·전량익절·트레일링이 전부 무력화되고 물타기 매수가 나갔다. # [2026-09-27 H-1] 당일 이미 SELL 체결된 보유 종목의 '부분매도' 반복 차단. # _guard() 가 보유 종목의 cooldown_sell_today 조기 SKIP 을 제거한 뒤부터, # ActiveTrade 행이 없는 보유분(브로커 sync·legacy strategy_name IS NULL)은 # remaining_pct 가 매 사이클 1.0 으로 간주되어 SELL_PARTIAL(0.5)이 무한 반복됐다. # → 전량 청산(손절·RSI≥70·트레일링)은 그대로 허용하고, 부분매도만 당일 1회로 제한한다. if _sold_today and should_sell and holding and _exit_sell_pct < 1.0: logger.info( f"[TRADE] {stock_code}/{stock_name} 부분매도 차단: cooldown_sell_today " f"(당일 매도 기록 존재, sell_pct={_exit_sell_pct*100:.0f}%, " f"reason={(_exit_signal.reason if _exit_signal else ' '.join(signals))}) " f"— 전량 청산 신호만 허용" ) return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name} if should_buy and should_sell and holding: logger.warning( f"[TRADE_CONFLICT] {stock_code}/{stock_name}: 동일 사이클에 매수·매도 신호 동시 발생 " f"→ 청산 우선 실행. has_rule={has_rule} score={score_val} " f"ml_signal={ml_signal_trade} ml_conf={ml_confidence_trade} " f"exit={(_exit_signal.reason if _exit_signal else None)} " f"sell_pct={_exit_sell_pct} signals={signals}" ) if should_sell and holding: logger.info(f"[TRADE] 📉 매도 실행: {stock_code}/{stock_name} @ {current_price:,.0f} ({_exit_sell_pct*100:.0f}%)") return await _execute_sell( stock, db, holding, current_price, signals, is_domestic, exchange_code, stock_code, stock_name, sell_pct=_exit_sell_pct, # [CLAUDE.md 5.2] 분할매도 비율 전달 ) if should_buy: # [M3] 리스크 가드(MDD/밴/보유수/당일매도쿨다운)는 신규 매수만 차단한다 if _buy_blocked_reason is not None: logger.info( f"[TRADE] {stock_code}/{stock_name} 매수 차단: {_buy_blocked_reason} (청산 판정은 수행됨)" ) return {"action": "SKIP", "reason": _buy_blocked_reason, "stock": stock_name} logger.info(f"[TRADE] 매수 실행: {stock_code}/{stock_name} @ {current_price:,.0f}") return await _execute_buy( stock, db, holding, current_price, signals, score_val, is_domestic, exchange_code, stock_code, stock_name, ) logger.info(f"[TRADE] ⏸️ HOLD: {stock_code}/{stock_name} (buy={should_buy}, sell={should_sell}, has_rule={has_rule})") # [2026-09-27 H1-A] BLOCK(과열·이격) 사유는 조기 return 제거 후에도 그대로 노출 — # 호출측 로깅·집계 호환을 위해 기존 reason 문자열을 유지한다. if _block_overheat_reason is not None: return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason} return {"action": "HOLD", "stock": stock_name, "reason": "no_buy_signal(진입조건 미충족)"} except ValueError as e: logger.error(f"[TRADE_VALIDATION] {stock.get('stock_code')}: {e}") return {"action": "ERROR", "error": str(e)} except Exception as e: logger.error(f"[TRADE_ERROR] {stock.get('stock_code')}: {type(e).__name__}: {e}", exc_info=True) return {"action": "ERROR", "error": str(e)}