"""자동매매 실행 엔진 (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, remaining_position_room, ) 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, ) from app.core.log_throttle import warn_throttled # noqa: E402 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 # [M-2 5회차 2026-10-02] 프로세스 내 (종목, 방향) in-flight 표 — Redis 락의 1차 방어. # 기존 주석은 "Redis 장애 시 세마포어가 2차 방어"라고 했지만 종목별 Semaphore(1) 은 **직렬화**만 # 한다 — 스윙 청산·DT·_execute_sell·수동 매도가 같은 종목 SELL 을 순서대로 **둘 다** 전송했다. # 여기에 이미 있으면(같은 프로세스에서 진행 중) Redis 결과와 무관하게 거부한다. # 값 = 획득 시각(monotonic). release 누락에 대비해 Redis 와 같은 TTL 이 지나면 무효. _inflight_order_locks: dict = {} def _local_order_lock_held_v2(key: tuple) -> bool: ts = _inflight_order_locks.get(key) if ts is None: return False if time.monotonic() - ts > _ORDER_LOCK_TTL: logger.warning(f"[order_lock] 로컬 in-flight {key} TTL 초과 — release 누락으로 보고 해제") _inflight_order_locks.pop(key, None) return False return True async def _acquire_order_lock(stock_code: str, order_type: str) -> bool: """주문 락 획득 (프로세스 내 in-flight 표 + Redis SETNX). 성공 시 True. [동작] 1. 같은 프로세스에서 같은 (종목, 방향) 주문이 진행 중이면 False — Redis 상태와 무관. 확인과 등록 사이에 await 가 없어 원자적이다(단일 이벤트 루프). 2. Redis SET NX + EX(TTL 60초) — 다른 프로세스/워커와의 중복 방지. - 키가 이미 있으면 False(로컬 등록도 취소) → 호출측 SKIP. - Redis 미연결·예외면 True(로컬 표만으로 진행). 여기서 False 로 막으면 Redis 장애 동안 손절 매도까지 전부 멈춘다(§8.1-11). 단일 프로세스 운영이므로 로컬 표로 중복이 막힌다. """ _local_key = (stock_code, str(order_type).upper()) if _local_order_lock_held_v2(_local_key): logger.warning(f"[order_lock] {stock_code} {order_type} — 같은 프로세스에서 주문 진행 중, 중복 차단") return False _inflight_order_locks[_local_key] = time.monotonic() try: from app.core.redis import get_redis client = get_redis() if not client: return True # Redis 미연결 → 로컬 in-flight 표로 보호 key = f"order_lock:{stock_code}:{order_type}" result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL) if result is True: return True _inflight_order_locks.pop(_local_key, None) # 다른 프로세스가 보유 — 로컬 등록 취소 return False except Exception as e: logger.warning(f"[order_lock] Redis 획득 오류 (로컬 in-flight 표로 진행): {e}") return True async def _release_order_lock(stock_code: str, order_type: str) -> None: """주문 락 해제 — 로컬 in-flight 표 먼저(항상), 그다음 Redis 키. [동작] finally 블록에서 반드시 호출 — 주문 성공/실패 관계없이 락 해제. Redis DELETE 가 실패하면 키가 TTL(60초)까지 남아 다른 경로의 다음 주문이 최대 1사이클 밀릴 수 있다(알려진 한계 — 로컬 표는 이미 해제됨). """ _inflight_order_locks.pop((stock_code, str(order_type).upper()), None) 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회) ─────────────────── # 재시도 대상 KIS msg_cd (레이트리밋). 업무 거부(잔고 부족·호가 오류·계좌 오류 등)는 # 재시도해도 같은 결과이고, 반복 주문이 일일 한도·쿨다운 판정을 오염시키므로 제외한다. # [M-2 3회차 2026-10-02] KIS 게이트웨이 토큰 무효(EGW00121/EGW00123)도 재시도 대상. # 게이트웨이 단계 거부라 주문이 접수되지 않은 것이 확실하고, korea_investment_api 가 이 코드에서 # 토큰을 재발급하므로 재시도하면 같은 사이클에 주문이 나간다(손절 매도가 1사이클 지연되던 문제). from app.services.korea_investment_api import KIS_TOKEN_INVALID_MSG_CODES as _KIS_TOKEN_INVALID _RETRYABLE_ORDER_MSG_CDS = frozenset({ "EGW00201", # 초당 거래건수 초과 *_KIS_TOKEN_INVALID, }) # [2026-10-02 r4] safe_place_order(_v2) 의 rt_cd "-1" 응답에 붙는 msg_cd. DT 가 이 값으로 # "주문이 KIS 에 전송됐는지"를 구분한다(전송 전 실패는 재시도 안전, 전송 후 불명은 체결 조회 필요). # korea_investment_api.place_order 가 자체 예외 처리에서 붙이는 KIS_PRESEND_CONNECT· # KIS_PRESEND_CIRCUIT_OPEN·TOKEN_UNAVAILABLE·KIS_TRANSPORT_UNKNOWN 과 같은 체계다. SAFE_ORDER_MSG_LOCK_BUSY = "ORDER_LOCK_BUSY" SAFE_ORDER_MSG_PRESEND_EXCEPTION = "KIS_PRESEND_EXCEPTION" SAFE_ORDER_MSG_TRANSPORT_UNKNOWN = "KIS_TRANSPORT_UNKNOWN" # [H-3 5회차 2026-10-02] '전송 전' 실패 = KIS 에 주문이 가지 않은 것이 확실 → 재전송 안전. from app.services.korea_investment_api import KIS_PRESEND_TOKEN_UNAVAILABLE as _KIS_PRESEND_TOKEN _PRESEND_ORDER_MSG_CDS = frozenset({ "KIS_PRESEND_CONNECT", "KIS_PRESEND_CIRCUIT_OPEN", _KIS_PRESEND_TOKEN, # TOKEN_UNAVAILABLE "KIS_PRESEND_EXCEPTION", # SAFE_ORDER_MSG_PRESEND_EXCEPTION "ORDER_LOCK_BUSY", # SAFE_ORDER_MSG_LOCK_BUSY }) def _is_retryable_order_failure(result: dict) -> bool: """주문 응답이 **재전송해도 안전한** 실패인지. rt_cd "0"(성공)은 호출부에서 먼저 걸러진다. [H-3 5회차] 재전송은 KIS 에 주문이 가지 않았거나 게이트웨이에서 거부된 것이 확실한 경우만: - 레이트리밋(EGW00201)·토큰 무효(EGW00121/123) — 게이트웨이 거부 - 전송 전 실패(KIS_PRESEND_*·TOKEN_UNAVAILABLE·ORDER_LOCK_BUSY) rt_cd "-1" 이라도 `KIS_TRANSPORT_UNKNOWN`·msg_cd 없음·응답 없음은 **전송 후 결과 불명**이라 재전송하면 이중 체결(4% 초과 매수·과다 매도)이 난다 → 재시도 대상이 아니다 (`_is_order_outcome_unknown_v2` 로 따로 판정). rt_cd "1" 등 업무 거부도 재시도하지 않는다. """ if not isinstance(result, dict) or not result: return False cd = str(result.get("msg_cd") or "") return cd in _RETRYABLE_ORDER_MSG_CDS or cd in _PRESEND_ORDER_MSG_CDS def _is_order_outcome_unknown_v2(result) -> bool: """주문이 KIS 에 접수됐는지 알 수 없는 응답인지(전송 후 불명). 응답 없음/빈 dict, 또는 rt_cd "-1" 인데 재전송 안전 코드가 아닌 경우 (KIS_TRANSPORT_UNKNOWN·msg_cd 없음). """ if not isinstance(result, dict) or not result: return True if str(result.get("rt_cd")) != "-1": return False return not _is_retryable_order_failure(result) 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"(성공) → 즉시 반환 3. 업무 거부(rt_cd "1" 등, 레이트리밋 msg_cd 제외) → 재시도 없이 즉시 반환 4. 네트워크/시스템 오류(rt_cd "-1")·레이트리밋: 1초→2초→4초 대기 후 최대 max_retries회 재시도 5. 최대 재시도 소진 시 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 ) # [H-1 2026-10-02] KIS 는 rt_cd "0" 만 접수 성공이다. 기존엔 "1" 을 # PENDING(접수)으로 보고 즉시 반환, 그 밖의 코드는 업무 거부(잔고 부족· # 호가 오류 등)까지 3회 재시도했다. 이제 재시도는 네트워크/시스템 오류 # (래퍼가 만든 rt_cd "-1")와 레이트리밋(msg_cd)만 — 업무 거부는 즉시 반환. if result.get("rt_cd") == "0": return result last_result = result if _is_order_outcome_unknown_v2(result): # [H-3 5회차] 전송 후 결과 불명 — 재전송 금지. 당일 주문내역에 보이면 그 결과를, # 아니면 불명 응답을 그대로 돌려준다(호출부가 PENDING 으로 기록하고 잔고 동기화로 # 확인). 주문내역 조회 실패도 []/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 logger.error( f"[ORDER_UNKNOWN] {stock_code} {order_type} {quantity}주 — 전송 후 결과 불명 " f"(msg_cd={result.get('msg_cd')} msg1={result.get('msg1')}). 이중 체결 방지로 " f"재전송하지 않음 — 잔고 동기화로 확인" ) return result if not _is_retryable_order_failure(result): logger.warning( f"[ORDER_REJECT] {stock_code} {order_type} KIS 거부 — 재시도 안 함 " f"(rt_cd={result.get('rt_cd')} msg_cd={result.get('msg_cd')} msg1={result.get('msg1')})" ) return 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가 주문을 접수한 뒤 응답만 못 받은 경우가 있으므로 # 재시도 전에 당일 체결 내역으로 중복 여부를 확인한다(멱등성 보강). # [H-3 5회차] place_order 호출 중 예외 = 전송 여부 불명 → 재전송 금지(위와 동일 정책). last_result = {"rt_cd": "-1", "msg_cd": SAFE_ORDER_MSG_TRANSPORT_UNKNOWN, "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 logger.error( f"[ORDER_UNKNOWN] {stock_code} {order_type} {quantity}주 — 주문 중 예외로 결과 불명 " f"({type(e).__name__}: {e}). 이중 체결 방지로 재전송하지 않음 — 잔고 동기화로 확인" ) return last_result 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.warning(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": "기존 주문 확인(중복 방지)", # [L-5 5회차] 같은 종목·방향·수량의 '아무' 주문일 수 있다 — 자기 주문이라는 # 보장이 없으므로 이 ODNO 로 취소하지 않는다(_execute_sell 이 이 표식을 본다). "_matched_by_lookup": True, "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", "msg_cd": SAFE_ORDER_MSG_LOCK_BUSY, "msg1": "order_lock_busy"} _sent = False # place_order 호출 진입 여부 — 예외 시 전송 전/후 구분 try: async with _get_order_semaphore(stock_code): _sent = True 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", "msg_cd": SAFE_ORDER_MSG_TRANSPORT_UNKNOWN, "msg1": "no_response"} except Exception as e: _cd = SAFE_ORDER_MSG_TRANSPORT_UNKNOWN if _sent else SAFE_ORDER_MSG_PRESEND_EXCEPTION logger.error(f"[SAFE_ORDER] {stock_code} {order_type} 실패({_cd}): {e}") return {"rt_cd": "-1", "msg_cd": _cd, "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: # 통과 근거는 _acquire_order_lock 과 동일(단일 프로세스 + 종목별 세마포어 2차 방어, # 차단 시 청산까지 정지). logger.warning(f"[order_lock_v2] user_id={user_id} 획득 오류 (패스 — 세마포어가 2차 방어): {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 as _swallowed: logger.warning("[order_lock_v2] 락 해제 실패(TTL 만료까지 유지): %s", _swallowed) 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", "msg_cd": SAFE_ORDER_MSG_LOCK_BUSY, "msg1": "order_lock_busy"} _sent = False try: async with _get_order_semaphore(semaphore_key): _sent = True 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", "msg_cd": SAFE_ORDER_MSG_TRANSPORT_UNKNOWN, "msg1": "no_response"} except Exception as e: _cd = SAFE_ORDER_MSG_TRANSPORT_UNKNOWN if _sent else SAFE_ORDER_MSG_PRESEND_EXCEPTION logger.error(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} 실패({_cd}): {e}") return {"rt_cd": "-1", "msg_cd": _cd, "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.warning(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.warning(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() _account_cache["deducted"] = False def _get_cached_account_v2() -> tuple | None: """`_get_cached_account` + '매수 차감으로 현금이 0 이 된 캐시' 도 신뢰. 기존 호출부는 현금 0 캐시를 불신(무한 0주 루프 방지)하고 portfolio_cache/KIS 로 재조회한다. 그러나 매수 접수로 차감된 0 은 진짜 0 이고, 재조회 소스(portfolio_cache· KIS 예수금)는 아직 그 매수를 반영하지 않았을 수 있다 → 차감된 캐시는 TTL 동안 그대로 쓴다. """ cached = _get_cached_account() if cached is None: return None if cached[0] > 0 or _account_cache.get("deducted"): return cached return None def _deduct_account_cache_v2(amount: float, available_cash: float, total_assets: float) -> None: """[H-2 2026-10-02] 매수 접수 성공 시 30초 계좌 캐시의 현금을 즉시 차감. 기존엔 캐시가 TTL 동안 매수 전 현금을 그대로 돌려줘, 같은 사이클의 다음 종목이 현금 95%(MAX_TOTAL_CAPITAL_USAGE)·스윙 70%(SWING_MAX_CAPITAL_RATIO) 가드를 매수 전 기준으로 통과했다. 총자산은 현금→주식 이동이라 그대로 둔다. 캐시가 없거나 만료됐으면 이번 판정에 쓴 값(available_cash/total_assets)에서 차감해 심는다. """ try: amt = max(0.0, float(amount)) except (TypeError, ValueError): amt = 0.0 cached = _get_cached_account() base_cash, base_assets = cached if cached is not None else (available_cash, total_assets) _account_cache["data"] = (max(0.0, float(base_cash) - amt), float(base_assets)) _account_cache["ts"] = time.monotonic() _account_cache["deducted"] = True # ── [M-3 2026-10-02] 사이클 내 매수 성공 종목 인메모리 누적 ─────────────────── # Portfolio 행은 db_worker 큐 경유로 비동기 반영되므로 같은 사이클(및 직후 사이클)의 # 보유수·섹터·레버리지·전략배분 판정이 방금 산 종목을 보지 못했다. # 매수 접수 성공 종목을 TTL 동안 기억해 판정에 합산한다(이미 Portfolio 에 보이면 중복 제외). _recent_cycle_buys: dict = {} # stock_code -> {"ts", "stock_name", "quantity", "price"} _RECENT_CYCLE_BUY_TTL_SEC = 300 # db_worker 반영 지연 흡수용(전략 파라미터 아님) def _note_cycle_buy_v2(stock_code: str, stock_name: str, quantity: int, price: float) -> None: _recent_cycle_buys[stock_code] = { "ts": time.monotonic(), "stock_name": stock_name or "", "quantity": int(quantity or 0), "price": float(price or 0), } def _recent_cycle_buys_v2() -> dict: """TTL 이내 매수 성공 종목 (만료분 정리 후 사본 반환).""" now = time.monotonic() for code in [c for c, v in _recent_cycle_buys.items() if now - v.get("ts", 0) > _RECENT_CYCLE_BUY_TTL_SEC]: _recent_cycle_buys.pop(code, None) return dict(_recent_cycle_buys) def _forget_cycle_buy_v2(stock_code: str, reason: str) -> None: """[M-4 3회차] 미체결·취소 확정 매수는 사이클 매수 누적에서 뺀다(보유수 과대 집계 방지).""" if _recent_cycle_buys.pop(stock_code, None) is not None: logger.warning(f"[CYCLE_BUY] {stock_code}: 사이클 매수 누적에서 제외 — {reason}") def _domestic_recent_cycle_buy_codes_v2() -> set: """국내 보유수 집계용 — 해외(USD) 매수는 MAX_HOLDINGS_COUNT 대상이 아니다.""" return {c for c in _recent_cycle_buys_v2().keys() if c and not is_us_stock(c)} def _domestic_recent_swing_buy_codes_v2() -> set: """[M-3 4회차] 사이클 매수 누적 중 국내 '스윙 소유' 매수 (일일 스윙 한도 집계용).""" return { c for c, v in _recent_cycle_buys_v2().items() if c and not is_us_stock(c) and (v.get("strategy_type") in (None, *_SWING_OWNER_STRATEGY_TYPES)) } # ── [M-1 3회차 2026-10-02] 업무 거부 매수 종목 단기 쿨다운 (인메모리) ────────── _order_reject_cooldown: dict = {} # stock_code -> monotonic ts _eval_rotation_offset: int = 0 # [L-3 4회차] 신규 후보 평가 시작 오프셋(사이클마다 회전) _account_reject_at: float | None = None # [L-2 4회차] 계좌 단위 거부 시각(monotonic) def _account_reject_cooldown_remaining_v2() -> float: """계좌 단위 거부 쿨다운 남은 초. 없으면 0.""" global _account_reject_at if _account_reject_at is None: return 0.0 left = settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN * 60 - (time.monotonic() - _account_reject_at) if left <= 0: _account_reject_at = None return 0.0 return left def _note_order_reject_v2(stock_code: str, order_result: dict) -> None: global _account_reject_at if str(order_result.get("msg_cd") or "") in set(settings.KIS_ACCOUNT_REJECT_MSG_CODES or ()): _account_reject_at = time.monotonic() logger.error( f"[ORDER_REJECT_COOLDOWN] 🚨 계좌 단위 거부 msg_cd={order_result.get('msg_cd')} " f"({order_result.get('msg1')}) — {settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN:g}분간 " f"전 종목 신규 매수 보류(청산은 계속). 계좌·앱키 상태 확인 필요" ) _order_reject_cooldown[stock_code] = time.monotonic() logger.warning( f"[ORDER_REJECT_COOLDOWN] {stock_code}: KIS 업무 거부 " f"(rt_cd={order_result.get('rt_cd')} msg_cd={order_result.get('msg_cd')} " f"msg1={order_result.get('msg1')}) — {settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN:g}분간 재주문 안 함" ) def _order_reject_cooldown_remaining_v2(stock_code: str) -> float: """남은 쿨다운(초). 없으면 0.""" ts = _order_reject_cooldown.get(stock_code) if ts is None: return 0.0 left = settings.AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN * 60 - (time.monotonic() - ts) if left <= 0: _order_reject_cooldown.pop(stock_code, None) return 0.0 return left def _merge_recent_buys_into_holdings_v2(holdings: list) -> list: """보유 리스트에 '아직 Portfolio 에 안 보이는' 최근 매수 종목을 의사 행으로 덧붙인다.""" from types import SimpleNamespace held = {getattr(h, "stock_code", None) for h in holdings} merged = list(holdings) for code, v in _recent_cycle_buys_v2().items(): if code in held: continue merged.append(SimpleNamespace( stock_code=code, stock_name=v["stock_name"], quantity=v["quantity"], current_price=v["price"], average_price=v["price"], strategy_type="swing", )) return merged def _leverage_exposure_v2(holdings: list, keywords) -> float | None: """보유 레버리지/인버스 ETF 평가액 합. 행 해석 실패 시 None(판정 불가).""" total = 0.0 for h in holdings: try: h_name = getattr(h, "stock_name", "") or "" if is_leverage_etf(h_name, keywords): total += float(getattr(h, "quantity", 0) or 0) * float( getattr(h, "current_price", 0) or getattr(h, "average_price", 0) or 0 ) except Exception as e: logger.warning( f"[LEVERAGE_LIMIT] 보유 행 평가 실패({getattr(h, 'stock_code', '?')}): {e}" ) return None return total def _parse_scale_v2(raw, name: str, stock_code: str) -> float: """배율 해석. None → 1.0(미주입), 0.0 은 0.0 그대로(진입 차단 의미 보존). [M-4 2026-10-02] 기존 `float(x or 1.0)` 은 0.0 을 1.0 으로 바꿔 국면/플레이북의 '진입 차단(0배)'을 무력화했다. 해석 불가 값은 안전한 쪽(0.0 = 매수 안 함) + warning. """ if raw is None: return 1.0 try: val = float(raw) except (TypeError, ValueError): logger.warning(f"[POSITION_SCALE] {stock_code}: {name}={raw!r} 해석 불가 — 0 으로 간주(매수 차단)") return 0.0 if val != val: # NaN logger.warning(f"[POSITION_SCALE] {stock_code}: {name}=NaN — 0 으로 간주(매수 차단)") return 0.0 return max(0.0, min(1.0, val)) # ── 지정가 조정 — 체결률 향상 ────────────────────────────────────────────────── def _resolve_sell_order_price_v2( stock_code: str, current_price: float, is_domestic: bool, full_exit: bool, profit_loss_rate: float, ) -> tuple: """[H-1 4회차] (기준가, 주문가 또는 None=시장가, 방식) — 순수 판정. - 해외: 기존 지정가(현재가 −0.5%) 유지 - 국내 전량 청산 또는 손실 청산: 시장가 (미체결로 수량을 잠그면 후속 손절이 거부된다) - 국내 부분 익절: 'limit_refresh' — 호출부가 주문 직전 현재가로 지정가를 재산정 """ if not is_domestic: return current_price, _adjust_order_price(current_price, "SELL", is_domestic), "limit_overseas" if full_exit or profit_loss_rate < 0: return current_price, None, "market" return current_price, None, "limit_refresh" async def _fresh_domestic_price_v2(stock_code: str) -> float: """주문 직전 국내 현재가. 실패/0 이면 0.0(호출부가 시장가로 폴백).""" try: data = await korea_investment_api.get_current_price(stock_code) return float(((data or {}).get("output") or {}).get("stck_prpr") or 0) except Exception as e: logger.warning(f"[SELL] {stock_code}: 주문 직전 현재가 조회 실패 — 시장가로 폴백: {e}") return 0.0 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.warning(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}") return 0.0 # ── 체결 확인 + 부분 체결 처리 + 슬리피지 로깅 ─────────────────────────────── async def _cancel_unfilled_order_v2( order_id: str, stock_code: str, remaining_qty: int, org_no: str | None = None, ) -> bool: """[H-1 4회차] 미체결 잔량 취소(자기 ODNO 만). 성공 True, 실패는 error 로그 후 False. [5회차] 인프라 `cancel_order_v2`(공식 order-rvsecncl 대조: KRX_FWDG_ORD_ORGNO 필수) 사용. org_no 는 원주문 응답에서 추출해 전달하고, None 이면 v2 가 당일 주문내역에서 찾는다. 못 찾으면 미전송 `ORGNO_NOT_FOUND` → 취소 미확정이므로 호출부는 롤백하지 않는다. """ try: result = await korea_investment_api.cancel_order_v2( order_id, org_no=org_no, quantity=remaining_qty, stock_code=stock_code, ) except Exception as e: logger.error(f"[VERIFY_EXEC] {stock_code} 미체결 매도 취소 실패(예외) order_id={order_id}: {e} — 수동 취소 필요") return False if (result or {}).get("rt_cd") == "0": logger.warning(f"[VERIFY_EXEC] {stock_code} 미체결 매도 {remaining_qty}주 취소 완료 (order_id={order_id})") return True logger.error( f"[VERIFY_EXEC] {stock_code} 미체결 매도 취소 거부 order_id={order_id} " f"rt_cd={(result or {}).get('rt_cd')} msg={(result or {}).get('msg1')} — 수동 취소 필요" ) return False def _extract_org_no_v2(order_result) -> str | None: """원주문 응답의 KRX_FWDG_ORD_ORGNO (취소에 필요). 추출 실패는 None — cancel_order_v2 가 조회.""" try: from app.services.korea_investment_api import extract_order_org_no return extract_order_org_no(order_result) except Exception as e: logger.warning(f"[SELL] 주문조직번호 추출 실패 — 취소 시 주문내역 조회로 대체: {e}") return None async def _rollback_active_trade_v2(stock_code: str, snapshot: dict | None, filled_qty: int) -> None: """[H-1 5회차] 취소된 지정가 부분 익절의 ActiveTrade 원복 (db_worker 큐). - 0체결: 주문 전 스냅샷(remaining_pct·quantity·highest_since_entry·전략 필터) 그대로 upsert → 같은 익절 단계가 다음 사이클에 다시 판정된다. created_at·strategy_name 은 upsert 가 건드리지 않는다(행을 닫지 않았으므로 보존). - 부분 체결: 잔량만 실제 체결 기준(스냅샷 수량 − 체결)으로 정정. remaining_pct 는 이미 진행한 단계값을 유지한다(같은 단계 재발동 방지). """ if not snapshot: return from app.services.db_worker import enqueue_active_trade_upsert payload = {k: v for k, v in snapshot.items() if v is not None} if filled_qty > 0: payload = {k: v for k, v in payload.items() if k not in ("remaining_pct", "highest_since_entry")} payload["quantity"] = max(0, int(snapshot.get("quantity") or 0) - int(filled_qty)) await enqueue_active_trade_upsert(payload) logger.warning( f"[VERIFY_EXEC] {stock_code} 취소된 지정가 익절 ActiveTrade 원복 " f"(체결 {filled_qty}주, payload={payload})" ) async def _mark_trade_cancelled_v2(order_id: str, db_trade_id: int, filled_qty: int = 0) -> None: """TradeHistory 를 CANCELLED 로 (db_worker 큐 경유).""" fields = {"status": "CANCELLED", "executed_quantity": int(filled_qty)} if db_trade_id: from app.services.db_worker import enqueue_update_trade_history await enqueue_update_trade_history(db_trade_id, fields) else: from app.services.db_worker import enqueue_update_trade_history_by_order_id await enqueue_update_trade_history_by_order_id(order_id, fields) 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: """기존 시그니처 유지 래퍼 — 미체결 자동 취소 없음(시장가·주문종류 불명 경로). [H-1 5회차] 미체결 취소는 `_verify_and_sync_execution_v2(cancel_unfilled_limit=True)` 로 **자기 ODNO 의 국내 지정가 부분 익절**에만 켠다. 시장가는 종가 단일가·VI·하한가에서 15초 넘게 미체결일 수 있고, 그 손절을 취소하면 청산이 사라진다. """ await _verify_and_sync_execution_v2( order_id, stock_code, stock_name, ordered_quantity, order_price, order_type, is_domestic, exchange_code, db_trade_id, delay=delay, ) async def _verify_and_sync_execution_v2( 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, cancel_unfilled_limit: bool = False, rollback_active_trade: dict | None = None, order_org_no: str | None = None, ) -> None: """[H-1 5회차] cancel_unfilled_limit=True: 폴링 종료 시 국내 SELL 잔량이 살아 있으면 취소. rollback_active_trade: 주문 전 ActiveTrade 스냅샷(전략 필터 포함). 0체결 취소 확정 시 그대로 upsert 해 remaining_pct·quantity·highest_since_entry 를 원복한다(행을 닫지 않는 부분 익절 전용). 주문 후 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" - 부분 체결 + KIS 잔량 소멸(rmn_qty == 0: 취소·거부·만료) → 체결분만 기록(COMPLETED) + warning("수동 확인"). **자동 재주문하지 않는다** (아래 ③) - 부분 체결 + 잔량이 KIS 에 살아 있음/모름 → DB 미갱신 + warning (재주문 금지) 5. 포트폴리오 동기화 트리거 [2026-10-01] `get_order_execution` 이 존재하지 않는 경로를 호출해 이 함수의 체결 반영 분기는 **한 번도 실행된 적이 없다**. 경로를 고치면서 처음 살아나는 분기의 결함을 함께 고쳤다: ① 첫 부분체결 관측에서 곧바로 break → 잔량이 아직 KIS 에 지정가 주문으로 살아 있는데 같은 수량을 재주문 = **중복 주문(과다 매수 / 과다 매도 시도)**. 이제 전량 체결 또는 잔량 소멸까지 폴링을 계속한다. ② status "PARTIAL" 은 매수 재진입 가드(당일 중복매수·일일횟수·쿨다운: EXECUTED/COMPLETED (/PENDING) 만 셈)와 손익 집계(COMPLETED/FILLED 만 셈)에서 빠진다 → 부분체결 BUY 가 당일 재매수를 허용하고 부분체결 SELL 이 성과에서 사라진다. 체결분만 수량에 반영하고 status 는 COMPLETED 를 유지한다. ③ 잔량 소멸 시 재주문도 제거했다. 잔량이 사라지는 경우는 사실상 사용자 HTS 취소·KIS 거부· 만료뿐이고(취소는 별도 행(orgn_odno)으로 기록돼 원주문 cncl_yn 이 N 일 수 있다), `_reorder_unfilled` 는 MDD fail-safe·밴·종목당 4%·현금·주문 세마포어/Redis 락· TradeHistory·체결 검증을 전부 우회한다. 취소 의도를 덮어쓰거나 가드 없는 주문을 내느니 warning 으로 수동 확인을 요청한다. `_reorder_unfilled` 는 시그니처 보존용으로만 남긴다. """ await asyncio.sleep(delay) # KIS 서버 체결 처리 대기 if not order_id: # order_id 없으면 체결 조회 불가 → 포트폴리오 동기화만 수행 await _delayed_sync_portfolio(0) return try: # KIS 체결 내역 조회 (inquire-daily-ccld, TR: TTTC0081R→폴백 TTTC8001R / 모의 V*) # [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응 filled_qty = 0 filled_price = 0.0 remaining_qty = None 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) or 0) filled_price = float(exec_result.get("filled_price", order_price) or order_price) remaining_qty = exec_result.get("remaining_qty") if filled_qty >= ordered_quantity: break # 전량 체결 if filled_qty > 0 and remaining_qty == 0: break # 부분 체결 후 잔량 소멸(취소·거부) — 더 기다려도 변하지 않음 if poll < max_polls - 1: logger.debug( f"[VERIFY_EXEC] {stock_code} {order_type} 체결 {filled_qty}/{ordered_quantity} " 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} " f"(조회성공={exec_result.get('ok')}, 주문행발견={exec_result.get('found')})" ) # [M-4 3회차] 주문행이 확인됐고 잔량 0(취소·거부·만료)이면 매수는 죽었다 → # 사이클 매수 누적에서 제외. 잔량이 살아 있거나 모르면 유지(보수적). if order_type == "BUY" and exec_result.get("found") and ( remaining_qty == 0 or exec_result.get("cancelled") ): _forget_cycle_buy_v2(stock_code, f"미체결·취소 확정 (order_id={order_id})") # [L-4 4회차] 체결 0 + 잔량 소멸 = 매수 불성립 → CANCELLED. COMPLETED 로 남으면 # 일일 스윙 슬롯·already_ordered_today·Kelly 통계가 허수 매수로 오염된다. await _mark_trade_cancelled_v2(order_id, db_trade_id, filled_qty=0) # [H-1 4회차] 국내 SELL 이 폴링 종료 시 잔량이 KIS 에 살아 있으면(지정가 미체결) 취소한다. # 남겨 두면 수량이 잠겨 swing/exit_service 의 시장가 손절이 종일 '주문가능수량 부족'으로 # 거부된다. 취소 성공 시 체결분만 기록(0 이면 CANCELLED), 실패 시 error. if ( cancel_unfilled_limit and order_type == "SELL" and is_domestic and filled_qty < ordered_quantity and isinstance(remaining_qty, (int, float)) and remaining_qty > 0 ): _cancelled = await _cancel_unfilled_order_v2( order_id, stock_code, int(remaining_qty), org_no=order_org_no, ) if _cancelled: if filled_qty <= 0: await _mark_trade_cancelled_v2(order_id, db_trade_id, filled_qty=0) await _rollback_active_trade_v2(stock_code, rollback_active_trade, filled_qty=0) await _delayed_sync_portfolio(0) return await _rollback_active_trade_v2(stock_code, rollback_active_trade, filled_qty=filled_qty) remaining_qty = 0 # 아래 '부분 체결 + 잔량 소멸' 경로로 체결분만 기록 if filled_qty > ordered_quantity: # 다른 주문 행과 혼동된 경우 등 — 주문 수량을 넘겨 기록하지 않는다 logger.warning( f"[VERIFY_EXEC] {stock_code} {order_type} 체결수량 {filled_qty} > 주문수량 " f"{ordered_quantity} — 주문수량으로 클램프 (order_id={order_id})" ) filled_qty = ordered_quantity # ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ───────────────── slippage_pct = 0.0 if filled_qty > 0 and 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}%" ) partial = 0 < filled_qty < ordered_quantity remainder_dead = partial and remaining_qty == 0 if partial and not remainder_dead: # 잔량이 KIS 에 미체결 지정가로 살아 있다(또는 모른다) → 재주문하면 중복 주문. # 최종 체결 수량이 아직 정해지지 않았으므로 원장도 건드리지 않는다(포트폴리오 sync 가 진실). logger.warning( f"[PARTIAL_FILL] {stock_code} {order_type} {filled_qty}/{ordered_quantity}주 체결, " f"잔량 {remaining_qty if remaining_qty is not None else '미상'}주 KIS 미체결 주문 유지 " f"— 재주문·원장 갱신 안 함 (order_id={order_id})" ) # ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ─── if filled_qty > 0 and (not partial or remainder_dead): update_fields = { "executed_quantity": filled_qty, "executed_price": Decimal(str(filled_price)), # PARTIAL 금지 — 위 docstring ② (가드·집계 누락) "status": "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) # ── 부분 체결 + 잔량 소멸 → 자동 재주문 금지, 수동 확인 요청 (docstring ③) ── if remainder_dead: remain = ordered_quantity - filled_qty logger.warning( f"[PARTIAL_FILL] {stock_code}/{stock_name} {order_type} order_id={order_id} " f"체결 {filled_qty}/{ordered_quantity}주, 잔량 {remaining_qty}주(미체결 {remain}주 소멸) " f"취소확인={exec_result.get('cancel_confirmed_qty')} 거부={exec_result.get('rejected_qty')} " f"cncl_yn={'Y' if exec_result.get('cancelled') else 'N'} — 자동 재주문 안 함, 수동 확인 필요" ) 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}") def _existing_position_qty_v2(holding, stock_code: str) -> int: """해당 종목의 기존 보유 수량 (종목당 누적 상한 산정용). Portfolio 행은 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있다(CLAUDE.md §3.5). §5.1 의 종목당 상한은 '계좌 내 그 종목 비중'이므로 **전략 구분 없이 전체**를 센다 (보수적). 단 DT 가 sync 이전에 산 수량은 행에 아직 없을 수 있으므로 `max(행 수량, DT 인메모리 수량)` 으로 잡는다 — 더하면 sync 이후 이중 계상된다. """ _row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0 try: from app.services.daytrading.position_manager import day_trading_state as _dts _dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0) except Exception as _e: logger.warning( f"[POSITION_CAP] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {_e}" ) _dt_qty = 0 return max(_row_qty, _dt_qty) def _apply_position_cap_room_v2( invest_amount: float, holding, stock_code: str, stock_name: str, current_price: float, total_assets: float, position_cap_ratio: float, ) -> tuple[float, str | None]: """종목당 누적 상한 적용. 반환 = (클램프된 금액, SKIP 사유 또는 None). 매수 예산 = min(기존 예산, 총자산 × position_cap_ratio − 기존 보유 평가액). 잔여 한도가 실효 최소주문금액(effective_min_buy_amount) 미만이면 'position_cap_reached' 로 건너뛴다. 보유가 있는데 총자산을 모르면(잔고 조회 실패) 한도를 검증할 수 없으므로 'position_cap_unverifiable' 로 추가매수를 막는다. 리스크 가드이므로 이 함수 자체에 try 로 감싼 판정이 없다(§4, §8.1-10). 신규 진입(보유 0)은 잔여 한도 = 총자산 × cap 이라 기존 1건 클램프와 동일하게 동작한다. """ _existing_qty = _existing_position_qty_v2(holding, stock_code) if _existing_qty <= 0: return invest_amount, None _existing_value = _existing_qty * float(current_price) if total_assets <= 0: logger.warning( f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_qty}주 " f"(평가 {_existing_value:,.0f}원)인데 총자산 미확인 — 종목당 상한 " f"{position_cap_ratio:.1%} 검증 불가로 추가매수 차단" ) return 0.0, "position_cap_unverifiable" _room = remaining_position_room(total_assets, position_cap_ratio, _existing_value) _min_buy = effective_min_buy_amount( config_min_amount=settings.MIN_BUY_AMOUNT_KRW, total_assets=total_assets, position_cap_ratio=position_cap_ratio, factor=0.9, ) if _room < _min_buy: logger.info( f"[POSITION_CAP] {stock_code}/{stock_name}: 종목당 상한 도달 — 기존 " f"{_existing_qty}주 평가 {_existing_value:,.0f}원 = 총자산의 " f"{_existing_value / total_assets:.2%} (상한 {position_cap_ratio:.1%}), " f"잔여 {_room:,.0f}원 < 최소주문 {_min_buy:,.0f}원 — 추가매수 건너뜀" ) return 0.0, "position_cap_reached" if invest_amount > _room: logger.info( f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_value:,.0f}원 반영 — " f"매수 예산 {invest_amount:,.0f} → {_room:,.0f}원 (종목당 상한 {position_cap_ratio:.1%})" ) return _room, None return invest_amount, None 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 as _swallowed: warn_throttled(logger, "trade_executor:813", "[FUND_FILTER] EPS 파싱 실패: %s", _swallowed) _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 as _swallowed: warn_throttled(logger, "trade_executor:822", "[FUND_FILTER] 목표가 파싱 실패: %s", _swallowed) except Exception as _ff_err: warn_throttled(logger, "trade_executor:825", 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 직접) ───── # [H-2 2026-10-02] _v2: 매수 차감으로 0 이 된 캐시는 신뢰(재조회 소스가 매수 전 값). cached = _get_cached_account_v2() # [FIX] 현금이 0인 캐시는 신뢰하지 않는다. 기존엔 available_cash=0이 캐시되면 # 그대로 재사용해 수량이 0으로 계산되어 매수 주문이 영영 안 나갔다(무한루프). if cached: 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 as _swallowed: warn_throttled(logger, "trade_executor:852", "[BUY] 포트폴리오 캐시 현금/자산 조회 실패(KIS 직접 조회로 진행): %s", _swallowed) # 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 # [M-3 3회차] 현금 정의 단일화 — buy_guards 와 같은 우선순위 # (ord_psbl_cash → prvs_rcdl_excc_amt → … → dnca_tot_amt). # dnca_tot_amt(D+2 예수금)는 당일 매수가 많으면 음수/과소라 최후순위. from app.services.buy_guards import resolve_available_cash_v2 available_cash = await resolve_available_cash_v2( korea_investment_api, b, stock_code ) 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) ────────────────────────────────── # 섹터 집중도 + 포트폴리오 상관계수 필터 # [M-1 2026-10-02] 조회·필터 실행만 try 안, 판정은 밖(§4). 기존엔 예외 시 # current_holdings=[] 로 통과해 섹터·상관 필터뿐 아니라 아래 레버리지 20% 집계와 # 전략 배분(_strategy_total_alloc)까지 0 으로 계산됐다 → 실패 시 신규 진입 차단. # [M-3] 같은 사이클에 방금 산(아직 Portfolio 미반영) 종목을 의사 행으로 합산한다. current_holdings = [] _corr_error: str | None = None is_valid, filter_reason = False, None try: # 현재 포트폴리오 보유 종목 조회 portfolio_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) current_holdings = _merge_recent_buys_into_holdings_v2( list(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 ) except Exception as e: _corr_error = f"{type(e).__name__}: {e}" if _corr_error is not None: logger.warning( f"[CORRELATION_FILTER_ERROR] {stock_code}: 보유/섹터/상관 판정 불가 — " f"신규 진입 차단(fail-closed): {_corr_error}" ) return {"action": "SKIP", "stock": stock_name, "reason": "correlation_filter_unavailable"} 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}") # ── 6.6. 레버리지/인버스 ETF 20% 한도 — [M-2] 사이징 이후(7.7)로 이동 ───────── # 신규 주문 금액을 포함해 판정해야 하므로 invest_amount 확정 뒤에 검사한다. _leverage_keywords = get_leverage_check_keywords() # ── 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% 이하인 모드만 적응형 값이 실제로 작동한다. # [§4 2026-10-02] 조회만 try 안. 기존엔 클램프·Crisis 차단이 try 안에 있어 # 예외 시 둘 다 조용히 건너뛰었다. 조회 실패 = Crisis 여부 불명 → 진입 차단. _adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4% = 절대 상한 _adp = None try: from app.services.adaptive_strategy import get_current_adaptive_params _adp = get_current_adaptive_params() except Exception as e: logger.warning( f"[ADAPTIVE] {stock_code}: 적응형 파라미터 조회 실패 — Crisis 여부 불명으로 " f"신규 진입 차단(fail-closed): {type(e).__name__}: {e}" ) return {"action": "SKIP", "reason": "adaptive_params_unavailable", "stock": stock_name} if _adp is None: logger.warning(f"[ADAPTIVE] {stock_code}: 적응형 파라미터 None — 신규 진입 차단(fail-closed)") return {"action": "SKIP", "reason": "adaptive_params_unavailable", "stock": stock_name} if not getattr(_adp, "fallback_used", False): # Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.4) if getattr(_adp, "trading_mode", None) == "crisis" and (getattr(_adp, "max_daily_trades", 0) or 0) <= 0: return {"action": "SKIP", "reason": "adaptive_crisis_mode", "stock": stock_name} try: _adaptive_position_cap = float(_adp.position_size) except (TypeError, ValueError, AttributeError) as e: logger.warning( f"[ADAPTIVE] {stock_code}: position_size 해석 실패 — MAX_POSITION_SIZE 사용: {e}" ) _adaptive_position_cap = settings.MAX_POSITION_SIZE # 절대 상한 클램프 — 무조건 적용 (CLAUDE.md §5.1 hard ceiling) _adaptive_position_cap = max(0.0, min(_adaptive_position_cap, settings.MAX_POSITION_SIZE)) 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) # ── 7.6. 종목당 **누적** 상한 (CLAUDE.md §5.1) ───────────────────────── # [2026-10-01] 위 클램프는 '주문 1건' 기준이라, 같은 종목이 매일 추천되면 # 추가매수가 쌓여 종목 비중이 MAX_POSITION_SIZE 를 넘었다. 기존 보유 평가액을 # 차감한 잔여 한도로 다시 묶는다. 리스크 가드이므로 try 밖에 둔다(§4, §8.1-10). _pos_cap_result = _apply_position_cap_room_v2( invest_amount=invest_amount, holding=holding, stock_code=stock_code, stock_name=stock_name, current_price=current_price, total_assets=total_assets, position_cap_ratio=_adaptive_position_cap, ) if _pos_cap_result[1] is not None: return {"action": "SKIP", "reason": _pos_cap_result[1], "stock": stock_name} invest_amount = _pos_cap_result[0] # [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을 # invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음. # [M-4 2026-10-02] `float(x or 1.0)` 는 0.0(진입 차단)을 1.0 으로 바꿨다. _position_multiplier = _parse_scale_v2( stock.get("_position_multiplier"), "_position_multiplier", stock_code ) _news_position_scale = _parse_scale_v2( stock.get("_news_position_scale"), "_news_position_scale", stock_code ) 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}" ) # ── 7.7. 레버리지/인버스 ETF 20% 한도 (CLAUDE.md §5.1) ───────────────── # [M-2 2026-10-02] 신규 주문분 포함: (보유 레버리지 평가액 + invest_amount) / 총자산. # 기존엔 보유분만 봐서 19% 보유 상태에서 4% 를 더 사 23% 가 될 수 있었다. # [M-1] 판정은 try 밖. 총자산·보유 평가를 알 수 없으면 한도 검증 불가 → 차단. if is_leverage_etf(stock_name, _leverage_keywords) and invest_amount > 0: _lev_total = _leverage_exposure_v2(current_holdings, _leverage_keywords) if _lev_total is None or total_assets <= 0: logger.warning( f"[LEVERAGE_LIMIT] {stock_code}/{stock_name}: 레버리지 한도 " f"{settings.LEVERAGE_ETF_MAX_RATIO*100:.0f}% 검증 불가 " f"(total_assets={total_assets:,.0f}) — 신규 진입 차단(fail-closed)" ) return {"action": "SKIP", "stock": stock_name, "reason": "leverage_limit_unverifiable"} _lev_ratio_after = (_lev_total + invest_amount) / total_assets if _lev_ratio_after > settings.LEVERAGE_ETF_MAX_RATIO: logger.warning( f"[LEVERAGE_LIMIT] {stock_code}/{stock_name}: 매수 후 레버리지 ETF 비중 " f"{_lev_ratio_after*100:.1f}% (보유 {_lev_total:,.0f} + 신규 {invest_amount:,.0f}) " f"> {settings.LEVERAGE_ETF_MAX_RATIO*100:.0f}% 한도" ) return {"action": "HOLD", "stock": stock_name, "reason": "leverage_etf_limit_exceeded"} # 투자금액 → 주수 환산 (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 as _swallowed: warn_throttled(logger, "trade_executor:1210", "[STORY] 뉴스 배율 스토리 생성 실패: %s", _swallowed) 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 as _swallowed: warn_throttled(logger, "trade_executor:1233", "[STORY] 목표가 스토리 생성 실패: %s", _swallowed) except Exception as _story_err: warn_throttled(logger, "trade_executor:1236", f"[STORY] {stock_code} 펀더멘털 스킵: {_story_err}") entry_reason = " | ".join(_story) success = order_result.get("rt_cd") == "0" # KIS 성공 코드 # [H-3 5회차] 전송 후 결과 불명 — 체결됐을 수 있다. 거부 쿨다운 대상 아님. _outcome_unknown = (not success) and _is_order_outcome_unknown_v2(order_result) if not success and not _outcome_unknown and not _is_retryable_order_failure(order_result): # [M-1 3회차] 업무 거부(rt_cd "1" 등) — 같은 종목 반복 주문 방지용 단기 쿨다운 _note_order_reject_v2(stock_code, order_result) # [H-1 2026-10-02] rt_cd≠"0" 은 KIS 거부/실패다. 기존엔 "1" 을 PENDING 으로 # 기록해 일일 스윙 한도·already_ordered_today·note_system_buy 가 거부 주문에 # 소진됐다. 체결 확인 전 상태는 _verify_and_sync_execution 이 갱신한다. # [H-3 5회차] 단, 결과 불명은 PENDING('접수·체결 미확인') — 일일 스윙 한도· # already_ordered_today·당일 매도 판정·유입잔고 강등 근거가 모두 PENDING 을 # 접수된 주문으로 센다. FAILED 로 두면 같은 사이클/다음 사이클이 재매수해 이중 체결. status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED") # [2026-09-30] 유입 잔고 강등 레이스 방어 — '시스템 매수' 를 **DB 반영 전에** # 인메모리로 즉시 등록한다. 아래 enqueue 들은 db_worker 큐를 거쳐 비동기로 # INSERT 되는데, `_verify_and_sync_execution()` 이 곧바로 `sync_portfolio()` # 를 돌리므로 그 사이에 sync 가 "근거 없음 → external 강등" 판정을 내리면 # 방금 산 포지션의 손절·익절이 통째로 멈춘다. # 주문 거부(FAILED)는 주식이 생기지 않으므로 등록하지 않는다 — 등록하면 # 기존 유입 잔고가 '매수 실패' 만으로 격리를 면한다. if success: from app.services.system_buy_registry import note_system_buy note_system_buy(stock_code, quantity) # [H-2] 같은 사이클의 현금 95%·스윙 70% 가드가 이 매수를 반영하도록 즉시 차감. # [Low 3회차] 계좌 캐시는 KRW(국내 예수금)다 — 해외(USD) 매수 금액을 빼면 # 단위가 섞여 국내 가용현금이 과소평가된다 → 국내 매수만 차감. if is_domestic: _deduct_account_cache_v2( float(order_price) * quantity, available_cash, total_assets ) # [M-3] 같은 사이클의 보유수·섹터·레버리지 판정에 합산. _note_cycle_buy_v2(stock_code, stock_name, quantity, float(order_price)) # [M-3 4회차] 일일 스윙 한도 집계용 전략 태그 (TradeHistory.strategy_type 과 같은 값) if stock_code in _recent_cycle_buys: _recent_cycle_buys[stock_code]["strategy_type"] = stock.get("strategy_type", "swing") elif _outcome_unknown: # [H-3 5회차] 체결됐을 수 있으므로 '산 것'으로 보수 집계(보유수·일일 한도·강등 근거). # 실제 여부는 잔고 동기화가 확정한다. 현금 캐시는 차감하지 않는다(과소 추정 회피 불필요). from app.services.system_buy_registry import note_system_buy note_system_buy(stock_code, quantity) _note_cycle_buy_v2(stock_code, stock_name, quantity, float(order_price)) if stock_code in _recent_cycle_buys: _recent_cycle_buys[stock_code]["strategy_type"] = stock.get("strategy_type", "swing") asyncio.create_task(_delayed_sync_portfolio(3)) # 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 as _swallowed: logger.warning("[BUY] portfolio_cache.apply_buy 실패: %s", _swallowed) # 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 as _swallowed: logger.warning("[BUY] WebSocket 갱신 트리거 실패: %s", _swallowed) # 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 as _swallowed: logger.warning("[BUY] 텔레그램 체결 알림 실패: %s", _swallowed) # 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, ) ) # [H-1] 기존 `elif is_pending:` 자동취소 분기 제거 — rt_cd≠"0" 은 거부라 # 취소할 주문이 없다(주문번호도 없음). 접수 성공 건의 미체결 정리는 # _verify_and_sync_execution 경로가 담당한다. 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") def _dt_inmemory_qty_v2(stock_code: str) -> int | None: """DT 인메모리 포지션 수량. 조회 실패 시 None(호출부가 '확인 불가'로 취급).""" try: from app.services.daytrading.position_manager import day_trading_state as _dts return int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0) except Exception as e: logger.warning(f"[SELL] {stock_code}: DT 인메모리 수량 조회 실패: {type(e).__name__}: {e}") return None # 자동 매도 대상이 아닌 Portfolio.strategy_type — 사용자 수동 판단 원칙. # external : sync 가 시스템 매수 근거 없이 발견한 유입 잔고(portfolio.EXTERNAL_STRATEGY_TYPE) # manual : manual_buy / portfolio_v2 수동 매수 _MANUAL_JUDGMENT_STRATEGY_TYPES = ("external", "manual") # 스윙 엔진이 '자기 것'으로 간주하는 strategy_type (NULL 레거시 행은 별도로 포함). # 매도 소유권(_sell_ownership_block_reason_v2)과 일일 매수 한도 집계 # (_count_swing_buys_today_v2)가 같은 기준을 쓴다 — 한쪽만 바꾸지 말 것. # swing/position_manager.STRATEGY_TYPE("swing") 과 동일. _SWING_OWNER_STRATEGY_TYPES = ("swing",) def _swing_owned_clause_v2(column): """스윙 소유 행 SQL 필터 = `column IS NULL OR column IN (_SWING_OWNER_STRATEGY_TYPES)`. NULL 을 명시 포함한다(SQL 3값 논리 — `IN` 만 쓰면 legacy NULL 행이 빠진다). """ return or_(column.is_(None), column.in_(_SWING_OWNER_STRATEGY_TYPES)) def _sell_ownership_block_reason_v2(holding, stock: dict) -> str | None: """추천 경로 매도(_execute_sell)가 이 보유 행을 팔아도 되는지 판정. swing/exit_service 가 `get_swing_holdings()`(strategy_type=='swing') 만 청산하는 것과 같은 기준이다. 반환이 None 이면 허용, 문자열이면 SKIP 사유. - external / manual → 'manual_judgment_holding' (warning) - DT 계열(DT_STRATEGY_NAMES) → 'owned_by_daytrading' — DT 엔진 소유 (단 호출자 태그도 DT 계열이면 자기 보유분이므로 허용) - 그 외 비-swing(us_swing 등) → 'owned_by_other_strategy' — 호출자 태그와 같을 때만 허용 strategy_type 이 비어 있으면 모델 기본값과 같이 'swing' 으로 본다. MDD 강제축소(mdd_manager)·DT 청산·swing/exit_service 는 이 함수를 거치지 않는다 (각자 주문 경로를 가진다) — 리스크 청산은 영향받지 않는다(§8.1-11). """ from app.services.db_worker import DT_STRATEGY_NAMES _holding_type = (getattr(holding, "strategy_type", None) or "swing") _caller_tag = stock.get("strategy_type") or "swing" if _holding_type in _SWING_OWNER_STRATEGY_TYPES: return None if _holding_type in _MANUAL_JUDGMENT_STRATEGY_TYPES: return "manual_judgment_holding" if _holding_type in DT_STRATEGY_NAMES: return None if _caller_tag in DT_STRATEGY_NAMES else "owned_by_daytrading" return None if _caller_tag == _holding_type else "owned_by_other_strategy" 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 기반) """ # ── 보유 행 소유권 가드 (2026-10-01) ───────────────────────────────────── # 추천 경로는 strategy_type 검사 없이 보유 행을 스윙 이름으로 팔았다 # (external·manual 유입 잔고, strategy_type='daytrading' 행 포함). # 이 함수의 호출자는 execute_auto_trade 하나뿐이며 MDD 강제축소·DT 청산· # swing/exit_service 는 별도 주문 경로라 영향이 없다. try 밖 가드(§4). _ownership_block = _sell_ownership_block_reason_v2(holding, stock) if _ownership_block is not None: _h_type = getattr(holding, "strategy_type", None) _msg = ( f"[SELL] {stock_code}/{stock_name}: 보유 {getattr(holding, 'quantity', '?')}주 " f"strategy_type='{_h_type}' — 추천 경로 자동매도 스킵({_ownership_block}, " f"요청 sell_pct={sell_pct*100:.0f}%, 호출 태그='{stock.get('strategy_type', 'swing')}')" ) if _ownership_block == "owned_by_daytrading": # [2026-10-01] DT 인메모리에 포지션이 없는데 daytrading 행이 남아 있으면 # 고아 행일 수 있다(재시작·stale 청산 실패 등) — 아무 엔진도 팔지 않으므로 # info 에 묻히지 않게 warning 으로 올린다. _dt_mem_qty = _dt_inmemory_qty_v2(stock_code) if _dt_mem_qty is None or _dt_mem_qty <= 0: logger.warning( _msg + f" — DT 인메모리 포지션 " f"{'조회 실패' if _dt_mem_qty is None else '0주'}인데 daytrading 행 " f"{getattr(holding, 'quantity', '?')}주가 남아 있음: 고아 행 가능성, " f"수동 확인 필요 ({stock_name}/{stock_code})" ) else: logger.info(_msg + f" — DT 엔진 소유(인메모리 {_dt_mem_qty}주)") elif _ownership_block == "manual_judgment_holding": logger.warning(_msg + " — 유입/수동 보유분은 수동 판단 대상") else: logger.warning(_msg + " — 다른 전략 엔진 소유") return {"action": "SKIP", "reason": _ownership_block, "stock": stock_name} # [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. 주문 실행 (종목별 세마포어 + 재시도) ────────────────────────── # [H-1 4회차 2026-10-02] current_price 는 추천 경로에서 StockAnalysis 배치 값(최대 30분 # 낡음)이다. 그 ×0.995 지정가는 하락 중 미체결로 남아 수량을 잠가 swing/exit_service 의 # 시장가 손절까지 거부됐다. → 국내: # - 전량 청산·손실 청산(손절) = 시장가(price=None) # - 부분 익절 = 주문 직전 현재가로 재산정한 지정가(조회 실패 시 시장가) # 해외는 기존 유지(US_TRADING_ENABLED=False, 시장가 미지원 경로). _ref_price, order_price, _order_kind = _resolve_sell_order_price_v2( stock_code, current_price, is_domestic, _full_exit, profit_loss_rate ) if _order_kind == "limit_refresh": _ref_price = await _fresh_domestic_price_v2(stock_code) if _ref_price > 0: order_price = _adjust_order_price(_ref_price, "SELL", is_domestic) else: _ref_price, order_price, _order_kind = current_price, None, "market_fallback" logger.info( f"[SELL] {stock_code}/{stock_name}: 주문 방식={_order_kind} " f"(가격={'시장가' if order_price is None else f'{order_price:,.2f}'}, 기준가={_ref_price:,.2f}, " f"배치가={current_price:,.2f}, 전량={_full_exit}, 손익률={profit_loss_rate:.2f}%)" ) 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, ) # 체결 확인·슬리피지 기준가 (시장가면 직전 기준가) _verify_ref_price = float(order_price) if order_price is not None else float(_ref_price) 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" # [H-1 2026-10-02] rt_cd≠"0" = 거부/실패 → FAILED (PENDING 으로 남기면 30초 # 중복매도 차단·당일 부분매도 차단이 거부 주문에 소진된다). # [H-3 5회차] 단 전송 후 결과 불명은 PENDING — 팔렸을 수 있으므로 30초 중복매도 차단· # 당일 매도 판정이 이 주문을 접수된 것으로 봐야 재매도(과다매도)가 나지 않는다. _outcome_unknown = (not success) and _is_order_outcome_unknown_v2(order_result) status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED") if _outcome_unknown: asyncio.create_task(_delayed_sync_portfolio(3)) # [H-1 5회차] 미체결 자동 취소 = 국내 지정가 부분 익절 + 자기 주문번호일 때만. # 시장가(전량·손절)는 절대 취소하지 않는다. 조회로 매칭된 주문(_matched_by_lookup)은 # 자기 주문이라는 보장이 없어 취소 대상에서 뺀다(L-5). _cancel_unfilled_limit = ( is_domestic and order_price is not None and not _full_exit and not order_result.get("_matched_by_lookup") ) _at_rollback: dict | None = None # 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). # [H1 2026-10-02] 단, 홀수 수량의 내림 매도로 실제 비율이 게이트 위에 # 남으면 같은 단계가 재발동하므로 단계값(이전 − sell_pct)을 상한으로 둔다. # 추적 불가(_rp_tracked=False)면 remaining_pct 는 1.0 으로 해석된다. from app.services.sizing_core import remaining_pct_after_partial_sell new_remaining = remaining_pct_after_partial_sell( remaining_pct, sell_pct, max(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), # 잔량 갱신(과다매도 방지) # [Low 3회차] 고점은 내려가면 안 된다 — 트레일링 스탑이 현재가 기준으로 # 리셋돼 느슨해지던 문제. max(기존 고점, 현재가). "highest_since_entry": max( float(stock.get("highest_since_entry") or 0.0), float(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 # [H-1 5회차] 지정가 익절이 0체결로 취소되면 되돌릴 주문 전 상태 if _cancel_unfilled_limit: _at_rollback = { k: v for k, v in _at_payload.items() if k not in ("remaining_pct", "quantity", "highest_since_entry") } _at_rollback.update({ "remaining_pct": remaining_pct if _rp_tracked else None, "quantity": int(total_qty), "highest_since_entry": ( float(stock["highest_since_entry"]) if stock.get("highest_since_entry") is not None else None ), }) 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 as _swallowed: logger.warning("[SELL] portfolio_cache 반영 실패: %s", _swallowed) # 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 as _swallowed: logger.warning("[SELL] WebSocket 갱신 트리거 실패: %s", _swallowed) # 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 as _swallowed: logger.warning("[SELL] 텔레그램 체결 알림 실패: %s", _swallowed) # 3초 후 KIS 체결 확인 (슬리피지 기록 포함) asyncio.create_task( _verify_and_sync_execution_v2( order_id=kis_order_id, stock_code=stock_code, stock_name=stock_name, ordered_quantity=quantity, order_price=_verify_ref_price, order_type="SELL", is_domestic=is_domestic, exchange_code=exchange_code, db_trade_id=0, # db_worker 큐 경유로 trade_id 미확보, order_id로 조회 delay=3, cancel_unfilled_limit=_cancel_unfilled_limit, rollback_active_trade=_at_rollback, order_org_no=_extract_org_no_v2(order_result), ) ) # [H-1] 기존 `elif is_pending:` 자동취소 분기 제거 (BUY 와 동일 사유). 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 _count_swing_buys_today_v2(db: AsyncSession) -> int | None: """당일 스윙 소유 BUY 종목 수 (distinct stock_code). 조회 실패 시 None. None 은 '모름'이다 — 호출부가 fail-closed(신규 매수 차단)로 처리한다. 여기서 0 으로 폴백하면 한도가 조용히 풀린다(이전 동작). """ try: _res = await db.execute( select(TradeHistory.stock_code) .where( TradeHistory.order_type == "BUY", TradeHistory.status.in_(["COMPLETED", "PENDING"]), TradeHistory.created_at >= get_daily_cutoff_utc(), _swing_owned_clause_v2(TradeHistory.strategy_type), ) .distinct() ) _codes = {c for c in _res.scalars().all() if c} except Exception as e: logger.error(f"[AUTO_TRADE_BG] 당일 스윙 매수 건수 조회 예외: {type(e).__name__}: {e}") return None # [M-3 4회차] db_worker 반영 지연 중인(아직 TradeHistory 에 안 보이는) 이번/직전 사이클 # 국내 스윙 매수를 합집합으로 더한다 — 지연 동안 MAX_DAILY_TRADES 를 넘겨 사는 것을 방지. # swing/entry_service 의 듀얼 선확인도 이 함수를 그대로 쓴다. _codes |= _domestic_recent_swing_buy_codes_v2() return len(_codes) def _regime_limits_v2(market_regime) -> tuple[int, float]: """check_market_regime() 결과 → (국면 max_trades, position_multiplier). - None(BEAR_MARKET_FILTER_ENABLED=False 로 조회 안 함): 기존과 동일하게 제한 없음 (MAX_DAILY_TRADES, 1.0). - dict 인데 regime_200d 가 없음 = 판정 실패 → (0, 0.0) fail-closed + error 로그. - max_trades 키가 없으면 MAX_DAILY_TRADES(장외 분기 등 기존 동작), 해석 불가면 0. - position_multiplier 키가 없으면 1.0(기존), 해석 불가면 0.0. """ if market_regime is None: return int(settings.MAX_DAILY_TRADES), 1.0 if not isinstance(market_regime, dict) or not market_regime.get("regime_200d"): logger.error( f"[AUTO_TRADE_BG] 🚨 시장 국면 판정값 없음({market_regime!r:.200}) — " f"신규 진입 차단(fail-closed)" ) return 0, 0.0 _mt = market_regime.get("max_trades") try: max_trades = int(settings.MAX_DAILY_TRADES) if _mt is None else int(_mt) except (TypeError, ValueError): logger.error(f"[AUTO_TRADE_BG] max_trades={_mt!r} 해석 불가 — 신규 진입 차단") max_trades = 0 _pm = market_regime.get("position_multiplier") try: pm = 1.0 if _pm is None else float(_pm) except (TypeError, ValueError): logger.error(f"[AUTO_TRADE_BG] position_multiplier={_pm!r} 해석 불가 — 0 배 적용") pm = 0.0 return max(0, max_trades), max(0.0, pm) 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() # [H3 2026-10-02] 필터가 켜져 있는데 국면 판정값이 없거나 형식이 틀리면 # (판정 실패) fail-closed. 기존 기본값(20건·1.0배)은 실패를 정상장으로 취급했다. regime_max_trades, position_multiplier = _regime_limits_v2(market_regime) max_trades = min(settings.MAX_DAILY_TRADES, regime_max_trades) # [M-2 4회차] 아래 게이트(국면·일일 한도·건수 조회 실패)는 **신규 매수만** 막는다. # 조기 return 하면 추천 목록에 있는 보유 종목의 청산 판정까지 사라졌다 → max_trades=0 # 으로 두고 루프에서 보유 종목만 _exit_only 로 평가한다(§8.1-11). _buy_block_reason: str | None = None if max_trades <= 0: _buy_block_reason = "regime_gate" logger.info( f"[AUTO_TRADE_BG] 국면 게이트 — max_trades=0 " f"(regime={market_regime.get('regime') if isinstance(market_regime, dict) else None}) " f"— 신규 진입 스킵, 보유 종목 청산 판정만 수행" ) # 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용 # (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨) # [2026-10-01] MAX_DAILY_TRADES 는 스윙 한도다 → 스윙 소유 매수(swing + NULL # 레거시)만 센다. DT·manual·external·us_swing 매수는 제외(매도 소유권과 같은 기준). # [2026-10-01] 조회 실패 시 fail-closed — 한도를 검증할 수 없으면 신규 매수를 # 막는다. 판정은 try 밖(§4). 스윙 청산은 swing/scheduler step 2 # (check_and_execute_exit_v2)가 이 함수와 독립적으로 수행하므로 영향이 없다. _bought_today = await _count_swing_buys_today_v2(db) if _buy_block_reason is None else 0 if _bought_today is None: _buy_block_reason = "daily_count_unverifiable" max_trades = 0 logger.error( f"[AUTO_TRADE_BG] 🚨 당일 스윙 매수 건수 조회 실패 — 일일 한도 " f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}종목 검증 불가로 " f"이번 사이클 신규 진입 차단(fail-closed). 보유 종목 청산 판정은 계속" ) elif _buy_block_reason is None: max_trades = max(0, max_trades - _bought_today) if max_trades == 0: _buy_block_reason = "daily_limit" logger.info( f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 스윙 {_bought_today}종목 / 한도 " f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵, " f"보유 종목 청산 판정만 수행" ) if _buy_block_reason is not None: max_trades = 0 # [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)), ) # [M-1 3회차 2026-10-02] 슬롯 = 'BUY & success' 건수. 기존 `sorted_stocks[:max_trades]` 는 # SKIP/HOLD(보유·당일매수·쿨다운 등)도 슬롯을 소비해, 상위에 이미 산 종목이 남으면 신규 # 후보를 평가조차 못 했다. 전체 후보를 순회하되 KIS 호출량은 '신규 후보 평가 상한'으로 묶고, # 매수 슬롯 소진 뒤에는 보유 종목만 청산 판정용으로 계속 평가한다(_exit_only). # DB-READ: 보유 종목 코드 SELECT 전용 _held_res = await db.execute(select(Portfolio.stock_code).where(Portfolio.quantity > 0)) _held_set = {c for c in _held_res.scalars().all() if c} _eval_cap = max(1, int(settings.AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE)) _buys_ok = 0 _evaluated_new = 0 _skipped_cooldown = 0 _skipped_cap = 0 # [L-3 4회차] 순회 순서 = 보유 종목(청산 판정 우선, 원래 정렬) → 신규 후보. # 신규 후보가 평가 상한보다 많으면 시작 오프셋을 사이클마다 회전해, 상위 HOLD 종목이 # 매 사이클 상한을 전부 차지해 하위 후보가 영영 평가되지 않는 것을 막는다. global _eval_rotation_offset _held_list = [x for x in sorted_stocks if isinstance(x, dict) and x.get("stock_code") in _held_set] _new_list = [x for x in sorted_stocks if not (isinstance(x, dict) and x.get("stock_code") in _held_set)] if len(_new_list) > _eval_cap and max_trades > 0: _off = _eval_rotation_offset % len(_new_list) _new_list = _new_list[_off:] + _new_list[:_off] _eval_rotation_offset = (_off + _eval_cap) % len(_new_list) else: _eval_rotation_offset = 0 for stock in _held_list + _new_list: if not isinstance(stock, dict): logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock) continue _code = stock.get("stock_code") _is_held = _code in _held_set # [L-2 4회차] 계좌 단위 거부 쿨다운 중이면 신규 매수 전부 보류(청산 판정은 계속) _slots_full = _buys_ok >= max_trades or _account_reject_cooldown_remaining_v2() > 0 if not _is_held: if _slots_full: continue if _order_reject_cooldown_remaining_v2(_code) > 0: _skipped_cooldown += 1 continue if _evaluated_new >= _eval_cap: _skipped_cap += 1 continue _evaluated_new += 1 try: stock["_position_multiplier"] = position_multiplier # 국면별 배율 주입 stock["_regime_str"] = ( (market_regime.get("regime_200d") or "bear") if isinstance(market_regime, dict) else "bear" ) if _slots_full: stock["_exit_only"] = True # 보유 종목: 청산 판정만, 추가매수 금지 result = await execute_auto_trade(stock, db) total_processed += 1 if result["action"] in ["BUY", "SELL"]: trade_results.append(result) if result["action"] == "BUY" and result.get("success"): _buys_ok += 1 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 logger.info( f"[AUTO_TRADE_BG] 사이클 요약: 후보 {len(sorted_stocks)} / 신규 평가 {_evaluated_new} " f"(상한 {_eval_cap}, 초과 {_skipped_cap}) / 매수 성공 {_buys_ok}/{max_trades} / " f"거부 쿨다운 스킵 {_skipped_cooldown}" ) # [관측성 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}") def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float: """ML 매수/HOLD 게이트 비교용 점수 산출 (0~100 스케일 기준). [배경 — 2026-09-28 스케일 불일치 수정] market_analyzer.py(:603-608) 는 룰 점수를 0~100 으로 정규화한 뒤 `rule*RULE_WEIGHT + ml*ML_WEIGHT` 로 블렌딩하고, 그 결과를 다시 0~15 로 축소해 StockAnalysis.score 에 저장한다. 즉 trade_executor 가 받는 stock["score"] 는 **이미 ML 이 반영된 0~RULE_SCORE_MAX 스케일** 값이다. (ml_score 키가 실린 모든 추천 경로 = recommendations.py:45 _stock_analysis_to_dict / recommendations.py:589 rec.model_dump() 가 동일하게 이 값을 전달한다.) 그런데 기존 게이트는 그 0~15 값을 정규화 없이 0~100 ML 점수와 다시 가중합해 ① 룰 기여 상한이 15×0.3=4.5 (임계 60 의 7.5%) 로 사실상 소멸 ② ml_score 를 이중 계산 하는 두 결함을 안고 있었다. ML_SCORE_NORMALIZE_ENABLED=False(기본) → 기존 계산식을 비트 단위로 그대로 유지. True → 재블렌딩을 제거하고 0~RULE_SCORE_MAX 를 0~100 으로 환산해 임계와 직접 비교. (ML 은 이미 score 안에 ML_WEIGHT 비중으로 반영되어 있다.) 임계값·가중치(ML_ENSEMBLE_BUY_THRESHOLD/RULE_WEIGHT/ML_WEIGHT)는 무변경. """ if not settings.ML_SCORE_NORMALIZE_ENABLED: # 기존 동작 (0~15 룰점수 + 0~100 ML점수 가중합) — 비트 동일 return ( rule_score * settings.ML_ENSEMBLE_RULE_WEIGHT + ml_score * settings.ML_ENSEMBLE_ML_WEIGHT ) rule_max = float(settings.RULE_SCORE_MAX or 15) if rule_max <= 0: rule_max = 15.0 return (rule_score / rule_max) * 100.0 def _today_sell_conditions_v2(stock_code: str, cutoff_time) -> list: """당일 SELL 판정 조건 (COMPLETED/PARTIAL 은 executed_at, PENDING 은 created_at 기준).""" return [ 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, ), ), ] async def _non_dt_sold_today_v2(db: AsyncSession, stock_code: str, cutoff_time) -> bool: """당일 비-DT SELL 존재 여부 — 스윙 보유분 '부분매도 반복 차단' 판정 전용. DT 판정은 db_worker.not_dt_strategy_clause 정본 표현(CLAUDE.md §3.3). 조회 실패 시 True(보수적: 부분매도만 막고 전량 청산은 허용 — 기존 동작과 동일). """ from app.services.db_worker import not_dt_strategy_clause try: _res = await db.execute( select(TradeHistory.id).where( *_today_sell_conditions_v2(stock_code, cutoff_time), not_dt_strategy_clause(TradeHistory.strategy_type), ).limit(1) ) return _res.first() is not None except Exception as e: logger.warning( f"[TRADE] {stock_code}: 당일 비-DT 매도 조회 실패 — 부분매도 차단으로 간주 " f"(전량 청산은 허용): {type(e).__name__}: {e}" ) return True 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/리츠 예외) ───────────────────────── # [M-6 2026-10-02] 판정만 여기서 하고, 적용은 보유 조회 뒤 `_guard` 로 한다. # 기존엔 즉시 SKIP 반환해 5,000원 이하로 떨어진 보유 종목의 손절·청산 판정이 # 막혔다(§8.1-11 청산 우선 위반). _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: _is_penny = current_price <= settings.MIN_PRICE_KRW and not _is_etf # CLAUDE.md 5.4: "초과" else: _is_penny = current_price <= settings.MIN_PRICE_USD and not _is_etf # CLAUDE.md 5.4: "초과" # ── 현재 보유 조회 (리스크 가드보다 먼저) ──────────────────────────── # [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 if _is_penny: _r = _guard("penny_stock") if _r is not None: return _r # ── 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 # ── 유입 격리분 추가매수 차단 ──────────────────────────────────────── # [2026-09-30] 유입 잔고(strategy_type='external') 행에 스윙 매수를 얹으면 # Portfolio 는 stock_code UNIQUE 로 수량이 합산되므로(CLAUDE.md §3.5) 행이 # swing 으로 승격되고 **유입분까지 스윙 자동 손절 대상이 된다.** # 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주 # → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 의 정면 위반이다. # # 여기서 막으면 그 혼재가 **발생하지 않는다** — 유입분 격리를 지키면서 # 시스템 매수분이 관리 밖에 남는 트레이드오프도 함께 사라진다(애초에 안 산다). # `_guard` 를 쓰므로 **청산 판정은 그대로 통과**한다(§8.1-11). # DT 경로(daytrading/entry_service)는 이 함수를 타지 않고, DT 는 인메모리로 # 자기 수량을 관리하므로 차단 대상이 아니다. if holding is not None and (holding.strategy_type or "swing") == "external": logger.warning( f"🚧 유입 격리분 추가매수 차단: {stock_name}({stock_code}) " f"보유 {holding.quantity}주 strategy_type='external' — " f"매수하면 행 전체가 swing 으로 승격돼 유입분이 자동 손절 대상이 된다" ) _r = _guard("external_inflow_holding") if _r is not None: return _r elif holding is not None: # [2026-10-01] "팔 수 없는 행에는 사지 않는다" — 매도 소유권과 대칭. # manual(수동매수)·daytrading(EOD 미청산 등)·us_swing 행에 스윙이 추가매수하면 # 그 수량은 swing/exit_service(=='swing' 만)도 _execute_sell 도 팔지 않아 # SL/TP 없는 보유가 된다. `_guard` 경유라 청산 판정은 그대로 통과(§8.1-11). _own_block = _sell_ownership_block_reason_v2(holding, stock) if _own_block is not None: logger.warning( f"🚧 비-스윙 보유 행 추가매수 차단: {stock_name}({stock_code}) " f"보유 {holding.quantity}주 strategy_type='{holding.strategy_type}' " f"({_own_block}) — 스윙 청산 경로가 이 행을 팔지 않으므로 " f"추가매수분이 SL/TP 없이 남는다" ) _r = _guard(f"non_swing_holding:{_own_block}") if _r is not None: return _r held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) # 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback) # [§4 2026-10-02] 폴백은 유지하되 조용히 넘기지 않는다(warning, 레이트리밋). _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 as _mh_err: warn_throttled( logger, "trade_executor:max_holdings_overlay", "[TRADE] 적응형 max_holdings 조회 실패 — MAX_HOLDINGS_COUNT 폴백: %s", _mh_err, ) # [M-3 2026-10-02] 같은 사이클에 방금 산(아직 Portfolio 미반영) 종목을 합산. _held_codes = {getattr(_h, "stock_code", None) for _h in held_result.scalars().all()} _held_count = len(_held_codes | _domestic_recent_cycle_buy_codes_v2()) if _held_count >= _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(*_today_sell_conditions_v2(stock_code, cutoff_time)) ) if recent_sell.first(): # 당일 재매수 차단은 DT 매도까지 포함한다(같은 종목 왕복 방지 — 기존 동작). _r = _guard("cooldown_sell_today") if _r is not None: return _r # [2026-10-01] 단 '부분매도 반복 차단'(_sold_today) 은 스윙 자신의 매도만 본다. # DT 가 같은 종목을 팔았다고 스윙 보유분의 부분익절이 당일 막히면 안 된다. _sold_today = await _non_dt_sold_today_v2(db, stock_code, cutoff_time) # [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) # [Low 3회차] 부분매도 시 고점을 낮추지 않도록 기존 고점을 함께 전달 _hse = getattr(_active_trade, "highest_since_entry", None) if _active_trade is not None else None if _hse is not None and stock.get("highest_since_entry") is None: stock["highest_since_entry"] = float(_hse) 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: # [2026-09-28] 스케일 불일치(0~15 룰점수 vs 0~100 임계) 및 ML 이중 # 계산 수정. ML_SCORE_NORMALIZE_ENABLED=False 면 기존 계산과 동일. # HOLD 게이트(*0.7)도 같은 weighted_score 를 쓰므로 스케일 일관. weighted_score = _ml_gate_score_v2(score_val, float(ml_score_trade)) 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: # [2026-10-02] ML 단독 SELL 은 보유분 전량매도(_exit_sell_pct=1.0, 룰 # 청산 판정 생략)를 유발한다. 운영 LSTM 은 재학습 전 사실상 난수 # (CLAUDE.md §8.5-20)이므로 ML_SELL_SIGNAL_ENABLED(기본 False)로 막는다. # 룰 기반 청산(check_exit_signal·ATR 손절·SELL_KEYWORDS)은 그대로. if settings.ML_SELL_SIGNAL_ENABLED: should_sell = True elif holding is not None: warn_throttled( logger, "trade_executor:ml_sell_disabled", "[ML_WEIGHT] %s: ML SELL 무시(재학습 전, ML_SELL_SIGNAL_ENABLED=False) " "conf=%s — 룰 기반 청산 판정으로 진행", stock_code, ml_confidence_trade, ) elif ( ml_signal_trade == "HOLD" and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7 ): should_buy = False # ML이 HOLD 강하게 권고 시 매수 억제 except Exception as e: warn_throttled(logger, "trade_executor:2366", 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: warn_throttled(logger, "trade_executor:2520", 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: # [M-1 3회차] 사이클 매수 슬롯 소진 후에는 보유 종목 청산 판정만 수행한다. if stock.get("_exit_only"): return {"action": "SKIP", "reason": "buy_slots_full", "stock": stock_name} _acct_left = _account_reject_cooldown_remaining_v2() if _acct_left > 0: return {"action": "SKIP", "reason": f"account_reject_cooldown({_acct_left/60:.0f}m)", "stock": stock_name} _rc_left = _order_reject_cooldown_remaining_v2(stock_code) if _rc_left > 0: return {"action": "SKIP", "reason": f"order_reject_cooldown({_rc_left/60:.0f}m)", "stock": stock_name} # [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)}