"""스윙 트레이딩 청산 서비스 기존 portfolio.py의 check_and_execute_exit_trades() 로직을 기반으로 strategy_type='swing' 포지션만 처리하도록 분리한 서비스. 청산 기준 (CLAUDE.md 섹션 5.2, 6.3): - ATR 손절: 진입가 - 2.5*ATR (하드스탑과 비교하여 타이트한 쪽) - 고정비율 폴백 손절: -7% (ATR 계산 불가 시) - 1차 익절: RSI >= 55 OR 가격 > BB 중앙선 OR 수익 > 2*ATR -> 50% 매도 - 2차 익절: RSI >= 70 OR 가격 > BB 상단(99%) -> 전량 매도 - 트레일링스탑: 고점 - 1.5*ATR (잔여 50% 물량) 전략별 차별화 (CLAUDE.md 8.4): - 평균회귀/눌림목: RSI 기반 단계별 익절 - 모멘텀 돌파: +10%/+20% 단계별 30% 매도 + 넓은 트레일링 아키텍처 규칙: - DB 기록: db_worker 큐 경유 (enqueue_trade_history, enqueue_close_active_trade) - loss_tracker: 전량 매도 시 record_trade_result_safe() + save_to_db() - 매도 주문: safe_place_order() 사용 (세마포어+분산락 보호) """ import asyncio import logging import time from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.config import settings from app.models.models import Portfolio, ActiveTrade from app.services.korea_investment_api import korea_investment_api from app.services.mean_reversion_strategy import ( calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal, MarketRegime, ) from app.services.market_analyzer import check_market_regime from app.services.mdd_manager import mdd_manager from app.services.loss_tracker import loss_tracker from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade from app.services.swing.position_manager import get_swing_holdings, get_swing_active_trade from app.services.utils import _is_domestic from app.core.json_helpers import _dumps from app.core.log_throttle import warn_throttled # noqa: E402 logger = logging.getLogger(__name__) # 종목별 마지막 청산 시각(epoch) — 큐 지연으로 같은 분할매도가 반복되는 것을 막는 인메모리 가드 _recent_exits: dict[str, float] = {} # [F5 2026-09-28] "스윙 보유 종목 없음" 연속 횟수 — 조용한 무동작을 드러내기 위한 카운터. # _EMPTY_HOLDINGS_WARN_AFTER 회 이상 연속되면 info → warning 으로 승격한다. _empty_holdings_streak: int = 0 _EMPTY_HOLDINGS_WARN_AFTER: int = 10 EXIT_COOLDOWN_SEC = 180 def _check_momentum_exit( current_price: float, entry_price: float, atr: float, remaining_pct: float, trailing_high: float | None, ) -> "ExitSignal | None": """모멘텀 돌파 전용 청산 로직 (CLAUDE.md 8.4). 일반 평균회귀와 다른 익절 전략을 사용: - 이익을 최대한 키우기 위해 RSI 기반 익절 대신 수익률 기반 단계별 매도 - 손절은 평균회귀와 동일 (ATR 2.5배 vs 하드스탑 중 타이트한 쪽) 익절 단계: 1. +10% 이상: 30% 매도 (remaining_pct > 0.75일 때만) 2. +20% 이상: 추가 30% 매도 (remaining_pct > 0.45일 때만) 3. 잔량: 트레일링스탑 (고점 - 1.5*ATR) Args: current_price: 현재가 entry_price: 진입가 atr: ATR(14) 값 remaining_pct: 잔여 비율 (1.0=전량 보유) trailing_high: 진입 이후 최고가 (트레일링스탑 기준) Returns: ExitSignal | None: 청산 신호, 없으면 None Note: [CRITICAL] 손절 계산에서 stop_price = max(atr_stop, pct_stop) 사용. 이것은 가격 비교이므로 max()가 올바름 (높은 가격 = 타이트한 손절). 음수 퍼센트로 비교해도 0 에 가까운(큰) 쪽이 타이트하므로 역시 max() 다 (CLAUDE.md §5.2). min() 이 맞는 곳은 장중 최소 손절폭 보장(§7) 뿐이다. """ from app.services.mean_reversion_strategy import ExitSignal if entry_price <= 0: return None profit_pct = (current_price - entry_price) / entry_price * 100 # 손절: ATR 2.5배 vs 하드스탑 (CLAUDE.md 5.2) # stop_loss_pct는 음수 (-7.0 등), pct_stop은 진입가 대비 하드스탑 가격 # max()를 사용하는 이유: 가격 비교에서 높은 값 = 진입가에 가까운 = 타이트한 손절 # [H4 2026-10-02] 손절률·ATR 배수는 평균회귀 check_exit_signal 과 같은 소스 # (적응형 → strategy_cfg → config, 고변동 ATR/종가>3% 면 STOP_LOSS_HIGH_VOL). # 기존: STOP_LOSS_PERCENT 고정 + 2.5×ATR 리터럴. from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2 stop_loss_pct, _atr_mult = resolve_swing_stop_params_v2(atr, current_price) atr_stop = entry_price - _atr_mult * atr pct_stop = entry_price * (1 + stop_loss_pct / 100) stop_price = max(atr_stop, pct_stop) if current_price <= stop_price: return ExitSignal( action="SELL_ALL", reason=f"모멘텀 손절 ({profit_pct:.1f}%, stop={stop_price:.0f})", sell_pct=1.0, ) # 트레일링스탑 (잔량에 대해) if trailing_high and trailing_high > entry_price: trail_stop = trailing_high - 1.5 * atr if current_price <= trail_stop and profit_pct > 0: return ExitSignal( action="SELL_ALL", reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f})", sell_pct=1.0, ) # 1차 익절: +10% → 30% 매도 (remaining > 0.65 = 아직 1차 매도 안 함) # 1차 매도 후 remaining = 1.0 - 0.30 = 0.70 → 다음 사이클에서 0.70 > 0.65 방지 # 따라서 0.75 기준 유지: 1.0 > 0.75 트리거, 매도 후 0.70 <= 0.75 → 재트리거 방지 if profit_pct >= 10.0 and remaining_pct > 0.75: return ExitSignal( action="SELL_PARTIAL", reason=f"모멘텀 1차 익절 +{profit_pct:.1f}% → 30% 매도", sell_pct=0.30, ) # 2차 익절: +20% → 30% 매도 # 1차 매도 후 remaining = 0.70, 2차 매도 후 remaining = 0.40 # 조건: remaining > 0.45 AND remaining <= 0.75 (1차와 겹치지 않게 상한 설정) if profit_pct >= 20.0 and 0.45 < remaining_pct <= 0.75: return ExitSignal( action="SELL_PARTIAL", reason=f"모멘텀 2차 익절 +{profit_pct:.1f}% → 30% 매도", sell_pct=0.30, ) return None # ── [M-2 2026-10-02 5회차] 결과 불명 매도 ───────────────────────────────────── # safe_place_order 의 msg_cd 체계(trade_executor·korea_investment_api): # 전송 전 실패 = KIS_PRESEND_* · TOKEN_UNAVAILABLE · ORDER_LOCK_BUSY (+ msg1 order_lock_busy) # 전송 후 불명 = KIS_TRANSPORT_UNKNOWN, msg_cd 없는 rt_cd "-1", 응답 자체 없음 _PRESEND_MSG_CDS_V2 = frozenset({"TOKEN_UNAVAILABLE", "ORDER_LOCK_BUSY"}) _TRANSPORT_UNKNOWN_MSG_CD_V2 = "KIS_TRANSPORT_UNKNOWN" # {종목: {"qty": 불명 주문수량, "pre_total": 주문 직전 Portfolio 합산수량, "since": epoch, "failures": n}} _pending_unknown_sells: dict[str, dict] = {} def _classify_swing_sell_response_v2(resp) -> str: """매도 주문 응답 → "accepted" | "unknown" | "retry". retry = 전송 전 실패(주문이 KIS 에 도달하지 않음) 또는 KIS 업무 거부(rt_cd "0"/"-1" 외) — 다음 주기 재시도가 안전하다. unknown = 접수 여부를 알 수 없음 — 확인 전 재매도 금지. """ if not isinstance(resp, dict) or resp.get("rt_cd") is None: return "unknown" rt = str(resp.get("rt_cd")) mc = str(resp.get("msg_cd") or "") if rt == "0": return "accepted" if mc.startswith("KIS_PRESEND_") or mc in _PRESEND_MSG_CDS_V2 or str(resp.get("msg1") or "") == "order_lock_busy": return "retry" if mc == _TRANSPORT_UNKNOWN_MSG_CD_V2 or (rt == "-1" and not mc): return "unknown" return "retry" async def _fetch_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None": """KIS 잔고에서 (보유수량, 주문가능수량). 조회 실패·오류 응답·형식 이상은 None. output1 에 종목이 없으면 정상 응답 = (0, 0). 주문가능수량이 없으면 보유수량으로 본다. 주문가능수량에는 대기 중인 매도 주문분이 이미 빠져 있다(미체결 불명 주문 포함). """ try: bal = await korea_investment_api.get_balance() except Exception as e: logger.warning(f"[SwingExit] KIS 잔고 조회 예외 ({stock_code}): {type(e).__name__}: {e}") return None if not isinstance(bal, dict) or bal.get("rt_cd") not in (None, "0"): return None rows = bal.get("output1") if not isinstance(rows, list): return None hq = sq = 0 found = False try: for h in rows: if not isinstance(h, dict) or str(h.get("pdno", "") or "") != stock_code: continue found = True _h = max(0, int(float(h.get("hldg_qty", 0) or 0))) _s = _h if h.get("ord_psbl_qty") not in (None, ""): _s = max(0, min(_h, int(float(h.get("ord_psbl_qty") or 0)))) hq += _h sq += _s except (TypeError, ValueError): return None # [7회차 H-A] get_balance 는 첫 페이지만 조회한다(연속조회 없음 — 모의 20행). 종목을 못 찾았는데 # 페이지가 가득 찼으면 "보유 0"이 아니라 "다음 페이지에 있을 수 있음" → 조회 실패(None)로 돌려 # 보류·연속 실패 error 경로를 타게 한다. "(0,0)"으로 두면 손절 주문이 영영 나가지 않는다. if not found and len(rows) >= int(settings.SWING_BALANCE_FULL_PAGE_ROWS): logger.error( f"[SwingExit] {stock_code} KIS 잔고 첫 페이지({len(rows)}행)에 없음 — 페이지 초과 가능, " "매도 보류(get_balance 연속조회 미구현, CLAUDE.md §8.5)" ) return None return hq, sq # [C-1 2026-10-02 6회차] 종목별 매도 직전 KIS 잔고 확인 연속 실패 횟수 _presell_check_failures: dict[str, int] = {} async def _presell_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None": """국내 스윙 매도 직전 KIS 실보유 (보유, 주문가능). 실패면 None(+warning, 연속이면 error). [C-1 6회차] 백엔드에는 주기적 Portfolio sync 가 없어 Portfolio 수량이 실보유보다 클 수 있다 (결과 불명 체결·외부 매도 등). 매도 수량 상한을 항상 KIS 실보유로 잡아 DT 물량 매도를 구조적으로 막는다. 조회 실패 시 매도 보류 — 청산 지연을 감수한다(과다매도보다 낫다). """ got = await _fetch_kis_holding_v2(stock_code) if got is None: n = _presell_check_failures.get(stock_code, 0) + 1 _presell_check_failures[stock_code] = n _msg = (f"[SwingExit] {stock_code}: 매도 직전 KIS 잔고 확인 실패 ({n}회 연속) — 매도 보류") if n >= int(settings.SWING_PRESELL_HOLDING_CHECK_ERROR_AFTER): logger.error("🚨 " + _msg + " (수동 확인 필요)") else: logger.warning(_msg) return None _presell_check_failures.pop(stock_code, None) return got def _trigger_portfolio_sync_v2() -> None: """[C-1(b)] 결과 불명 매도 직후 Portfolio sync 를 백그라운드로 요청(trade_executor 와 같은 경로). sync_portfolio 는 CLAUDE.md §3.2 허용 예외 ① 이다. 실패는 warning — 장부 정정의 1차 수단은 다음 주기의 KIS 잔고 대조(_reconcile_unknown_sell_v2)다. """ try: from app.services.trade_executor import _delayed_sync_portfolio asyncio.get_running_loop().create_task(_delayed_sync_portfolio()) except Exception as e: logger.warning(f"[SwingExit] 결과 불명 매도 후 Portfolio sync 요청 실패: {type(e).__name__}: {e}") async def _reconcile_unknown_sell_v2(stock_code: str, kis_hldg: int, kis_sellable: int) -> bool: """결과 불명 매도의 체결분을 KIS 실보유 감소로 확인해 장부에 반영. 반영했으면 True. [C-1(a) 6회차] 체결분 = min(주문수량, 주문 직전 KIS 보유 − 현재 보유). 체결분이 있으면 COMPLETED TradeHistory·reflect_sell·ActiveTrade(분할이면 의도 단계 remaining_pct, 전량이면 close + loss_tracker + save_to_db)로 반영하고 대기를 해제한다 — 호출부는 그 주기를 skip 해 같은 단계 재발동을 막는다. 체결분이 없고 미체결 주문도 없으면(주문가능=보유) 미접수로 보고 대기를 해제, 주문가능<보유(미체결 주문 잔존)면 대기를 유지한다. """ pend = _pending_unknown_sells.get(stock_code) if not pend: return False filled = min(int(pend["qty"]), max(0, int(pend["pre_hldg"]) - int(kis_hldg))) if filled <= 0: if kis_sellable >= kis_hldg: _pending_unknown_sells.pop(stock_code, None) logger.warning( f"[SwingExit] {stock_code}: 결과 불명 매도({pend['qty']}주) 미체결 확인 " f"(KIS 보유 {kis_hldg}주 불변) — 대기 해제, 다음 주기 재판정" ) return False _pending_unknown_sells.pop(stock_code, None) price = float(pend["price"]) avg = float(pend["avg_price"]) swing_qty = int(pend["swing_qty"]) is_full = filled >= swing_qty logger.warning( f"[SwingExit] {stock_code}: 결과 불명 매도 체결 확인 — {filled}/{pend['qty']}주 " f"(KIS 보유 {pend['pre_hldg']}→{kis_hldg}) → 장부 반영" ) await enqueue_trade_history({ "stock_code": stock_code, "stock_name": pend["stock_name"], "order_type": "SELL", "order_price": price, "order_quantity": int(pend["qty"]), "executed_price": price, "executed_quantity": filled, "total_amount": price * filled, "status": "COMPLETED", "order_id": "", "exit_reason": f"{pend['reason']} (결과 불명 → KIS 잔고로 체결 확인)", "net_profit_loss": (price - avg) * filled, "profit_loss_rate": ((price - avg) / avg * 100) if avg > 0 else 0.0, "strategy_type": "swing", }) _recent_exits[stock_code] = time.time() from app.services.portfolio_cache_bridge import reflect_sell await reflect_sell(stock_code, filled, price, remaining_qty=int(kis_hldg)) from app.services.db_worker import DT_STRATEGY_NAMES as _DTN, enqueue_db_write, enqueue_active_trade_upsert if is_full: if pend["filter_not_dt"]: await enqueue_db_write({"type": "close_active_trade", "data": {"stock_code": stock_code, "strategy_name_not": list(_DTN)}}) else: await enqueue_close_active_trade(stock_code, strategy_name=pend["strategy"] or "swing") await loss_tracker.record_trade_result_safe(stock_code=stock_code, is_loss=(price - avg) < 0) await loss_tracker.save_to_db(stock_code=stock_code) _last_live_prices.pop(stock_code, None) _last_atr.pop(stock_code, None) else: from app.services.sizing_core import remaining_pct_after_partial_sell new_remaining = remaining_pct_after_partial_sell( float(pend["remaining_pct"]), float(pend["sell_pct"]), swing_qty - filled, int(pend["original_qty"]), ) data = {"stock_code": stock_code} if pend["filter_not_dt"]: data["strategy_name_not"] = list(_DTN) else: data["strategy_name"] = pend["strategy"] or "swing" data.update({"remaining_pct": new_remaining, "quantity": swing_qty - filled}) if not pend["had_active_trade"]: data.update({ "stock_name": pend["stock_name"], "entry_price": avg, "current_price": price, "strategy_name": "swing", "status": "active", "entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)", }) await enqueue_active_trade_upsert(data) return True # [L-4] 종목별 마지막 실시세(성공 조회값). 현재가 조회 실패 시 손절 판정용 대체 가격. # [H-1 2026-10-02] (가격, monotonic 시각, KST wall 시각). 나이 상한·진입 시각 비교·전량 청산 시 # 폐기를 위해 시각을 함께 저장한다(이전엔 가격만 있어 재매수 포지션에 예전 가격이 쓰였다). _last_live_prices: dict[str, tuple] = {} # [M-4 6회차] 종목별 "현재가·대체가 모두 없음" 연속 skip 횟수 _no_price_skips: dict[str, int] = {} async def _alert_no_price_v2(stock_code: str, n: int) -> None: """손절 판정 연속 skip 종목별 텔레그램 시스템 경고. 실패는 warning(판정 루프 무영향).""" try: from app.services.telegram_notifier import telegram_notifier await telegram_notifier.notify_system_warning( "SWING_EXIT_NO_PRICE", f"{stock_code}: 현재가·대체가 없음으로 스윙 손절 판정 {n}회 연속 skip — 수동 확인 필요", ) except Exception as e: logger.warning(f"[SwingExit] {stock_code} 손절 skip 텔레그램 경보 실패: {type(e).__name__}: {e}") # [H-1 6회차] 직전 정상 지표 경로의 ATR — (atr, monotonic, KST wall). 대체가 손절 판정용. _last_atr: dict[str, tuple] = {} def _remember_atr_v2(stock_code: str, atr) -> None: try: a = float(atr) except (TypeError, ValueError): return if a > 0: from app.core.timeutil import now_kst _last_atr[stock_code] = (a, time.monotonic(), now_kst()) def _stale_fallback_stop_pct_v2(stock_code: str, price: float, entry_at) -> float: """현재가 조회 실패(대체가) 시 손절률(음수 %). [H-1 6회차] 대체가 경로는 일봉을 조회하지 않아 ATR 이 None → 고변동 종목도 일반 손절 (-7%)로 판정돼 고변동 손절(-10%) 전에 오손절됐다. - 직전 정상 지표 경로의 ATR 이 유효하면(나이 ≤ SWING_FALLBACK_PRICE_MAX_AGE_SEC, 진입 이후 값) 그 ATR 로 고변동 여부를 판정한 손절률 - 없으면 일반·고변동 손절률 중 **가장 느슨한** 값(사용자 결정: 오손절 방지) 해석 실패 시 바닥값도 config 의 두 손절률 중 느슨한 쪽. """ _floor = -max(abs(float(settings.STOP_LOSS_PERCENT)), abs(float(settings.STOP_LOSS_HIGH_VOL))) atr = None cached = _last_atr.get(stock_code) if isinstance(cached, tuple) and len(cached) == 3: _a, _mono, _wall = cached _fresh = (time.monotonic() - _mono) <= float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC) try: _after_entry = entry_at is None or _wall >= entry_at except TypeError: _after_entry = False if _fresh and _after_entry: atr = _a try: from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2 if atr is not None: sl, _ = resolve_swing_stop_params_v2(atr, price) else: sl_normal, _ = resolve_swing_stop_params_v2(None, price) # ATR/종가 > 3% 를 강제해 고변동 손절률을 얻는다(ATR 값 자체는 쓰지 않음) sl_high, _ = resolve_swing_stop_params_v2(price, price) sl = min(sl_normal, sl_high) except Exception as e: logger.warning( f"[SwingExit] 대체가 손절률 해석 실패 ({stock_code}) — 바닥값 {_floor:.1f}% 사용: " f"{type(e).__name__}: {e}" ) return _floor return -abs(float(sl)) def _remember_live_price_v2(stock_code: str, price: float) -> None: from app.core.timeutil import now_kst _last_live_prices[stock_code] = (float(price), time.monotonic(), now_kst()) async def _fetch_live_price_v2(stock_code: str, market: str, domestic: bool) -> "float | None": """KIS 실시간 현재가(>0) 또는 None. 조회 예외·응답 없음·빈 값·파싱 불가는 None(+warning).""" try: if domestic: resp = await korea_investment_api.get_current_price(stock_code) raw = ((resp or {}).get("output") or {}).get("stck_prpr") else: resp = await korea_investment_api.get_overseas_price(stock_code, market) raw = ((resp or {}).get("output") or {}).get("last") if raw is None or str(raw).strip() == "": return None price = float(raw) except Exception as e: warn_throttled( logger, "exit_service:live_price", f"[SwingExit] 현재가 조회 실패 ({stock_code}): {type(e).__name__}: {e}", ) return None if price != price or price <= 0: return None return int(price) if domestic else price def _fallback_price_v2(stock_code: str, holding) -> "float | None": """하위 호환 위임 — 진입 시각 미지정 `_fallback_price_v3`.""" return _fallback_price_v3(stock_code, holding, None) def _fallback_price_v3(stock_code: str, holding, entry_at) -> "float | None": """현재가 조회 실패 시 손절 판정용 대체 가격, 없으면 None. [H-1/M-1 2026-10-02] 후보 = 직전 실시세 캐시 · Portfolio.current_price(updated_at). - 나이 > SWING_FALLBACK_PRICE_MAX_AGE_SEC 이면 후보 탈락(캐시는 monotonic, Portfolio 는 updated_at 기준; updated_at 이 없으면 나이를 모르므로 탈락) - entry_at(ActiveTrade.created_at) 이전 시각의 값은 탈락 — 예전 보유분 가격이 재매수 포지션 손절에 쓰여 시세 조회 1회 실패로 오손절·loss_tracker 오염이 나던 문제 - 남은 후보 중 **더 최신** 값을 쓴다(우선순위 고정 아님) 낡은 고가 때문에 익절이 막히는 것은 의도다(대체 가격은 손절 판정에만 쓰인다). """ from app.core.timeutil import now_kst max_age = float(settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC) now_wall = now_kst() candidates: list[tuple] = [] # (wall 시각, 가격) def _not_before_entry(ts) -> bool: if entry_at is None: return True try: return ts >= entry_at except TypeError: return False # 비교 불가 = 진입 이후임을 보장 못 함 → 탈락 cached = _last_live_prices.get(stock_code) if isinstance(cached, tuple) and len(cached) == 3: _p, _mono, _wall = cached if _p and _p > 0 and (time.monotonic() - _mono) <= max_age and _not_before_entry(_wall): candidates.append((_wall, float(_p))) try: cp = float(getattr(holding, "current_price", 0) or 0) except (TypeError, ValueError): cp = 0.0 upd = getattr(holding, "updated_at", None) if cp > 0 and upd is not None: try: _age = (now_wall - upd).total_seconds() except TypeError: _age = None if _age is not None and _age <= max_age and _not_before_entry(upd): candidates.append((upd, cp)) if not candidates: return None try: return max(candidates, key=lambda c: c[0])[1] except TypeError: return candidates[0][1] def _fallback_stop_loss_pct_v2(atr, latest_price: float, stock_code: str = "") -> float: """폴백 손절률(음수 %). 해석 실패 시 config 하드 바닥값 -abs(STOP_LOSS_PERCENT). [C-1 2026-10-02 3회차] 지표 경로와 같은 resolve_swing_stop_params_v2 가 공통 원인으로 예외를 던져도 폴백 손절은 반드시 판정돼야 한다. 해석만 try 안에 두고, 반환값은 try 밖에서 음수 부호 가드를 다시 건다(양수가 새면 전 종목 매도 사고). """ _floor = -abs(float(settings.STOP_LOSS_PERCENT)) _resolved = None try: from app.services.mean_reversion_strategy import resolve_swing_stop_params_v2 _resolved, _ = resolve_swing_stop_params_v2(atr, latest_price) except Exception as e: logger.warning( f"[SwingExit] 폴백 손절률 해석 실패 ({stock_code}) — config 바닥값 " f"{_floor:.1f}% 사용: {type(e).__name__}: {e}" ) _resolved = None if _resolved is None: return _floor return -abs(float(_resolved)) async def _resolve_time_stop_entry_v2(db: AsyncSession, stock_code: str, active_trade): """시간손절 기준 진입일 보정 — 부분매도 시 자동 생성된 ActiveTrade 의 created_at 리셋 보완. [H-3 2026-10-02 3회차] ActiveTrade 가 없던 포지션을 부분매도하면 exit_service 가 entry_reason="동기화 유입 포지션 (부분매도 시 자동 생성)" 행을 새로 만들어 created_at 이 **매도일**로 찍힌다 → 시간손절 시계가 0 으로 돌아간다. 그런 행만 TradeHistory 에서 원래 진입일을 찾는다. [L-3 2026-10-02 4회차] 탐색 범위 = (이 종목의 **마지막 전량 청산** 시각, 행 created_at]. 마지막 전량 청산은 같은 종목 비-DT ActiveTrade 중 status='closed' 행의 최신 updated_at (db_worker close_active_trade 가 닫을 때 찍는 시각)이다. 이전엔 하한이 없어 과거에 이미 정리된 포지션의 BUY 를 진입일로 써서 즉시 시간손절이 날 수 있었다. 범위 안에서는 **가장 최근** COMPLETED 비-DT BUY 일자를 쓴다(실제 최초 진입보다 늦을 수는 있어도 이르지는 않다 → 조기 청산 없음, 늦게 발동할 수는 있음). 범위 안 BUY 가 없거나 조회가 실패하면 기존 created_at 그대로. 한계: 전량 청산이 ActiveTrade 를 거치지 않은 경로(외부 매도 등)만 있었다면 하한이 잡히지 않는다 — 그 경우에도 결과는 created_at 이전의 최근 BUY 다. 반환: created_at 을 덮은 대리 객체 또는 원본. SELECT 전용(commit 없음) — CLAUDE.md §3.2 ③-b(스윙 스케줄러 SELECT, 시간손절 TradeHistory SELECT 포함). """ if active_trade is None: return None _reason = getattr(active_trade, "entry_reason", None) or "" _created = getattr(active_trade, "created_at", None) if not _reason.startswith("동기화 유입") or _created is None: return active_trade _orig = None try: from sqlalchemy import func from app.models.models import TradeHistory from app.services.db_worker import not_dt_strategy_clause _closed_res = await db.execute( select(func.max(ActiveTrade.updated_at)).where( ActiveTrade.stock_code == stock_code, ActiveTrade.status == "closed", ActiveTrade.updated_at <= _created, not_dt_strategy_clause(ActiveTrade.strategy_name), ) ) _last_full_close = _closed_res.scalar() _conds = [ TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status == "COMPLETED", TradeHistory.created_at <= _created, not_dt_strategy_clause(TradeHistory.strategy_type), ] if _last_full_close is not None: _conds.append(TradeHistory.created_at > _last_full_close) _res = await db.execute(select(func.max(TradeHistory.created_at)).where(*_conds)) _orig = _res.scalar() except Exception as e: warn_throttled( logger, "exit_service:time_stop_entry", f"[SwingExit] 시간손절 원 진입일 조회 실패 ({stock_code}) — created_at 사용: {e}", ) _orig = None if _orig is None or not hasattr(_orig, "date"): return active_trade try: _is_earlier = _orig < _created except TypeError as e: # tz-aware/naive 혼합 등 — 청산 루프를 막지 않는다 warn_throttled( logger, "exit_service:time_stop_entry_cmp", f"[SwingExit] 시간손절 원 진입일 비교 실패 ({stock_code}) — created_at 사용: {e}", ) return active_trade if not _is_earlier: return active_trade from types import SimpleNamespace return SimpleNamespace(created_at=_orig) def _time_stop_signal_v2(active_trade, latest_price: float, avg_price: float): """ActiveTrade.created_at(KST naive) 기준 시간손절 신호(ExitSignal) 또는 None. 기간·최소수익은 settings.SWING_TIME_STOP_DAYS / SWING_TIME_STOP_MIN_PROFIT_PCT. [H-3 2026-10-02 3회차] 보유일은 **KRX 거래일**(주말·휴장일 제외) 기준이다 (check_time_stop_exit → market_schedule.count_krx_trading_days_between). 진입일을 알 수 없으면(행 없음·created_at 없음) 판정하지 않는다(None). """ from app.services.mean_reversion_strategy import ExitSignal, check_time_stop_exit from app.core.timeutil import now_kst if active_trade is None or avg_price <= 0: return None _created = getattr(active_trade, "created_at", None) if _created is None: return None try: entry_date = _created.date() if hasattr(_created, "date") else _created except Exception as e: warn_throttled(logger, "exit_service:time_stop_date", "[SwingExit] 시간손절 진입일 해석 실패: %s", e) return None profit_pct = (latest_price - avg_price) / avg_price * 100 _ts = check_time_stop_exit( entry_date=entry_date, current_date=now_kst().date(), profit_pct=profit_pct, max_days=int(settings.SWING_TIME_STOP_DAYS), min_profit_pct=float(settings.SWING_TIME_STOP_MIN_PROFIT_PCT), ) if not _ts: return None return ExitSignal(action="SELL_ALL", reason=_ts["reason"], sell_pct=1.0) async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: """스윙 포지션 손절/익절 체크 및 자동 매도 실행. portfolio.py의 check_and_execute_exit_trades()와 동일한 로직이나 strategy_type='swing' 필터를 적용해 데이트레이딩 포지션을 제외함. 스케줄러에서 60초 주기로 호출. 처리 흐름 (종목별): 1. 스윙 보유 종목 조회 (get_swing_holdings) 2. 시장 국면 조회 (BULL/BEAR) 3. 실시간 현재가 조회 (KIS API) 4. ActiveTrade 조회 (분할 청산 상태) 5. 전략별 청산 분기: - MOMENTUM_BREAKOUT: _check_momentum_exit() - 평균회귀/눌림목/기본: check_exit_signal() 6. 고정비율 폴백 손절 (-7%) 7. 매도 수량 결정 (소량 분할 방지) 8. KIS API 매도 주문 실행 9. DB 기록 + loss_tracker 업데이트 Args: db: AsyncSession 인스턴스 Returns: list[dict]: 청산 실행 결과 목록 """ global _empty_holdings_streak executed_exits = [] try: # strategy_type='swing' 포지션만 조회 _now_ts = time.time() holdings = await get_swing_holdings(db) if not holdings: # [F5 2026-09-28] 이 로그가 debug 였기 때문에 2026-05-15~09-21 4개월간 # 청산 판정이 사실상 멈춰 있던 것이 운영 로그에서 드러나지 않았다. # info 로 올리고, 연속으로 계속되면 warning 으로 승격해 눈에 띄게 한다. _empty_holdings_streak += 1 if _empty_holdings_streak >= _EMPTY_HOLDINGS_WARN_AFTER: logger.warning( f"[SwingExit] 스윙 보유 종목 없음 — {_empty_holdings_streak}회 연속 " f"(청산 판정이 계속 no-op 이다: 실제 무보유인지, " f"Portfolio.strategy_type 불일치인지 확인 필요)" ) else: logger.info( f"[SwingExit] 스윙 보유 종목 없음 ({_empty_holdings_streak}회 연속)" ) return executed_exits _empty_holdings_streak = 0 # 시장 국면 (동적 계산 — 기존 코드의 하드코딩 BULL 개선) try: regime_dict = await check_market_regime() regime_str = regime_dict.get("regime_200d", "bull") if isinstance(regime_dict, dict) else "bull" regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR except Exception as e: logger.warning(f"[SwingExit] 시장 국면 조회 실패 — BULL 폴백(청산 판정 계속): {type(e).__name__}: {e}") regime = MarketRegime.BULL # 폴백 for holding in holdings: try: stock_code = holding.stock_code stock_name = holding.stock_name or stock_code # [H2 2026-09-27] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 # 스윙+데이트레이딩 수량이 합산돼 있다. 행 전체를 청산 대상으로 잡으면 # DT 보유분까지 팔려 DT 인메모리와 불일치(주문거부/과다매도)가 생긴다. # trade_executor._execute_sell 과 동일하게 DT 인메모리 수량을 차감한 # '스윙 보유분'만 대상으로 한다. total_qty = int(holding.quantity or 0) try: from app.services.daytrading.position_manager import day_trading_state as _dts _dt_pos = _dts.positions.get(holding.stock_code) _dt_qty = int((_dt_pos or {}).get("quantity", 0) or 0) except Exception as e: # 국내 매도 직전 KIS 실보유 상한(C-1)이 2차 방어이므로 0 폴백 유지 + warning logger.warning(f"[SwingExit] {holding.stock_code} DT 보유분 조회 실패 — 0 으로 간주: {e}") _dt_qty = 0 quantity = max(0, total_qty - _dt_qty) if quantity <= 0: logger.info( f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 " f"— 스윙 보유분 없음, 청산 스킵" ) continue if _dt_qty > 0: logger.info( f"[SwingExit] {stock_code}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 " f"→ 스윙 {quantity}주 대상" ) avg_price = float(holding.average_price or 0) market = getattr(holding, "market", None) or ( "KRX" if _is_domestic(stock_code) else "NASD" ) domestic = _is_domestic(stock_code, market) # ── 실시간 현재가 조회 ──────────────────────────────────── # [L-4 2026-10-02 4회차] 조회 예외·빈 문자열(stck_prpr=="")이 종목 전체 try 로 # 빠져 폴백 손절까지 건너뛰던 것을 막는다. 조회·파싱만 try 안 # (_fetch_live_price_v2). 실패하면 대체 가격(직전 실시세 → Portfolio 평가가) # 으로 **손절 판정만** 하고(익절·트레일링·시간손절은 낡은 가격으로 하지 않음), # 대체 가격도 없으면 error 로그(레이트리밋) 후 스킵 — 청산 우선(§8.1-11). if avg_price <= 0: continue # ── ActiveTrade 조회 (분할 청산 상태) ──────────────────── # [H-1 2026-10-02] 대체 가격의 "진입 이전 값 거부"에 진입 시각이 필요해 현재가 # 조회 앞으로 옮겼다(조회 자체는 동일). active_trade = await get_swing_active_trade(db, stock_code) _live = await _fetch_live_price_v2(stock_code, market, domestic) _price_stale = _live is None if not _price_stale: latest_price = _live _remember_live_price_v2(stock_code, _live) _no_price_skips.pop(stock_code, None) else: _entry_at = getattr(active_trade, "created_at", None) if active_trade else None latest_price = _fallback_price_v3(stock_code, holding, _entry_at) if latest_price is None: _key = f"exit_service:no_price:{stock_code}" from app.core.log_throttle import should_log _ok, _sup = should_log(_key) _n_skip = _no_price_skips.get(stock_code, 0) + 1 _no_price_skips[stock_code] = _n_skip _alert_n = max(1, int(settings.SWING_FALLBACK_SKIP_ALERT_AFTER)) if _n_skip % _alert_n == 0: await _alert_no_price_v2(stock_code, _n_skip) if _ok: logger.error( f"[SwingExit] 🚨 {stock_code} 현재가 조회 실패 + 유효한 대체 가격 없음" f"(나이 ≤ {settings.SWING_FALLBACK_PRICE_MAX_AGE_SEC:.0f}초·진입 이후 값 없음) — " f"손절 판정 skip, 수동 확인 요청" + (f" (+{_sup}건 억제)" if _sup else "") ) continue logger.warning( f"[SwingExit] {stock_code} 실시간 현재가 조회 실패 — 대체 가격 " f"{latest_price:,.2f} 로 손절만 판정" ) # (ActiveTrade 는 위 현재가 조회 전에 이미 읽었다 — H-1) # ActiveTrade upsert/close 시 항상 전략명을 명시해야 같은 종목의 DT 행을 # 잘못 건드리지 않는다(db_worker 는 전략명이 없으면 최신 행을 매칭). # [2026-09-27 후속] strategy_name 이 NULL 인 legacy/동기화 유입 행을 # 'swing' 으로 폴백하면 DB 에 'swing' 행이 없어 매칭 0건 → # 유령 active 행이 영구 잔존한다. trade_executor 와 동일하게 # payload 의 strategy_name_not=DT_STRATEGY_NAMES 로 '비-DT 최신 행'을 # 직접 겨냥한다(전략명이 있으면 기존 동작 그대로). _at_strategy = (active_trade.strategy_name if active_trade else None) or "swing" _at_filter_not_dt = bool( active_trade is not None and not active_trade.strategy_name ) remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0 trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None # [S6] 트레일링스탑 고점(highest_since_entry) 매 사이클 갱신 if active_trade and not _price_stale and latest_price > (trailing_high or 0): trailing_high = latest_price from app.services.db_worker import enqueue_active_trade_upsert _th_payload = {"stock_code": stock_code} if _at_filter_not_dt: # 전략명 NULL 인 비-DT 행 대상 — 이름 동등 매칭 불가. # strategy_name 과 동시에 넣으면 우선순위상 필터가 무력화된다. from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_TH _th_payload["strategy_name_not"] = list(_DTN_TH) else: _th_payload["strategy_name"] = _at_strategy # DT 행 오염 방지 _th_payload["highest_since_entry"] = trailing_high await enqueue_active_trade_upsert(_th_payload) # ── [C-1(a) 6회차] 결과 불명 매도 대조 ───────────────────────── # 신호 유무와 무관하게 매 주기 KIS 실보유로 체결분을 확인해 장부에 반영한다. # 반영한 주기는 skip(같은 단계 재발동·부풀린 수량 재매도 방지). _kis_snapshot = None if domestic and stock_code in _pending_unknown_sells: _kis_snapshot = await _presell_kis_holding_v2(stock_code) if _kis_snapshot is None: continue # 대조 불가 — 이 주기 매도 보류(경고는 헬퍼가 남김) if await _reconcile_unknown_sell_v2(stock_code, *_kis_snapshot): continue # ── 전략별 청산 분기 ──────────────────────────────────── exit_signal = None _strategy = _at_strategy # [H4 2026-10-02] 지표 경로(check_exit_signal/_check_momentum_exit)가 실제로 # 평가됐는지. 평가됐으면 그 안의 손절이 이미 판정했으므로 폴백을 돌리지 않는다. # [C-1 2026-10-02 3회차] True 는 판정 함수가 **정상 반환한 뒤에만** 세운다. # 이전엔 호출 전에 세워, 판정 함수가 예외를 던지면 폴백 손절까지 건너뛰어 # -20% 포지션에도 주문이 0건이었다(손절 공백). _indicator_evaluated = False _fallback_atr = None _ind = None # (atr, rsi, bb, lows) — 판정 가능한 지표가 모였을 때만 채운다 if domestic and not _price_stale: try: chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30) chart_data = chart_resp.get("output2", []) if len(chart_data) >= 20: closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)] highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)] lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)] atr = calculate_atr(highs, lows, closes) rsi = calculate_rsi(closes) bb = calculate_bollinger(closes) _fallback_atr = atr _remember_atr_v2(stock_code, atr) if atr and rsi is not None and bb: _ind = (atr, rsi, bb, lows) except Exception as e: warn_throttled( logger, "exit_service:indicator_fetch", f"[SwingExit] 일봉/지표 산출 실패 ({stock_code}) — 폴백 손절로 판정: {e}", ) if _ind is not None: atr, rsi, bb, lows = _ind try: bb_upper, bb_middle, bb_lower = bb lowest_since = min(lows[-5:]) if lows else avg_price # 모멘텀 돌파 전용 청산 (CLAUDE.md 8.4) if _strategy == "MOMENTUM_BREAKOUT": exit_signal = _check_momentum_exit( latest_price, avg_price, atr, remaining_pct, trailing_high, ) else: # 평균회귀 / 눌림목 / 기본 스윙 exit_signal = check_exit_signal( regime=regime, rsi=rsi, close=latest_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, ) _indicator_evaluated = True # 정상 반환한 경우에만 except Exception as e: exit_signal = None # 청산 판정 실패는 손절 공백으로 직결되므로 스로틀하지 않는다. logger.warning( f"[SwingExit] 청산 판정 함수 예외 ({stock_code}, {_strategy}) — " f"폴백 손절로 판정: {type(e).__name__}: {e}", exc_info=True, ) # ── 시간손절 (CLAUDE.md §6.3) ───────────────────────────── # [2026-10-02] check_time_stop_exit 는 호출부가 없었다. 평균회귀·눌림목 # 계열(모멘텀 제외 — 자체 단계 익절/트레일링)에만 배선하고 # SWING_TIME_STOP_ENABLED(기본 True)로 게이트한다. 전량 신호가 이미 # 있으면 그대로 두고, 없거나 부분매도면 시간손절(전량)이 우선한다. if ( settings.SWING_TIME_STOP_ENABLED and not _price_stale and _strategy != "MOMENTUM_BREAKOUT" and (exit_signal is None or getattr(exit_signal, "sell_pct", 1.0) < 1.0) ): # 시간손절 판정 예외가 아래 폴백 손절까지 끌고 내려가지 않도록 격리한다 # (예외 시 기존 exit_signal 유지 — 청산 우선 §8.1-11). _ts_signal = None try: _ts_basis = await _resolve_time_stop_entry_v2(db, stock_code, active_trade) _ts_signal = _time_stop_signal_v2(_ts_basis, latest_price, avg_price) except Exception as e: logger.warning( f"[SwingExit] 시간손절 판정 예외 ({stock_code}) — 건너뜀: " f"{type(e).__name__}: {e}" ) if _ts_signal is not None: exit_signal = _ts_signal # ── 고정 비율 폴백 손절 ─────────────────────────────────── # [H4 2026-10-02] 지표 경로가 **판정 불가**(해외·차트<20봉·ATR/RSI/BB 산출 # 실패·조회 예외)일 때만 돈다. 이전엔 지표 경로가 평가 후 "청산 없음"을 내도 # STOP_LOSS_PERCENT(7%) 고정으로 다시 판정해, 고변동 종목(-10%)을 지표 경로보다 # 먼저 손절했다. 손절률은 check_exit_signal 과 같은 소스 # (resolve_swing_stop_params_v2: 적응형 → strategy_cfg → config, ATR 을 알면 고변동 포함). # [C-1 2026-10-02 3회차] 폴백이 지표 경로와 같은 resolve_swing_stop_params_v2 를 # 쓰므로 공통 원인(비정상 strategy_cfg 값·None 적응형 필드)으로 함께 실패할 수 # 있다. 해석만 try 안에 두고, 실패하면 config 하드 바닥값 # -abs(STOP_LOSS_PERCENT) 로 판정한다. 판정 자체는 try 밖(§4·§8.1-11). if exit_signal is None and not _indicator_evaluated: if _price_stale: # [H-1 6회차] 대체가 판정은 직전 정상 지표 경로의 ATR(고변동 여부)로, # 없으면 가장 느슨한 설정 손절률로만 판정한다(오손절 방지). _fb_sl_pct = _stale_fallback_stop_pct_v2( stock_code, latest_price, getattr(active_trade, "created_at", None) if active_trade else None, ) else: _fb_sl_pct = _fallback_stop_loss_pct_v2(_fallback_atr, latest_price, stock_code) profit_rate = ((latest_price - avg_price) / avg_price) * 100 if profit_rate <= _fb_sl_pct: from app.services.mean_reversion_strategy import ExitSignal exit_signal = ExitSignal( action="SELL_ALL", reason=f"고정비율 손절 ({profit_rate:.1f}%)", sell_pct=1.0, ) if not exit_signal: continue # [2026-09-23] 상태 저장(remaining_pct·Portfolio)이 db_worker 큐 경유라 # 큐 지연·DLQ 시 다음 사이클이 옛 값을 읽어 같은 분할매도를 반복한다. # 인메모리 쿨다운으로 직전 청산 직후 재매도를 차단한다. _last_exit = _recent_exits.get(stock_code) if _last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC: logger.info( f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전 " f"— 쿨다운({EXIT_COOLDOWN_SEC}초) 중 스킵" ) continue # ── 매도 수량 결정 (소량 분할 방지) ───────────────────── # [M5 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율. # check_exit_signal / _check_momentum_exit 의 remaining_pct 게이트가 # 1.0 → 0.70 → 0.40 (원본 대비 누적)을 가정하므로, 매도 수량도 반드시 # 원본 수량 기준으로 계산해야 remaining_pct 와 실제 잔량이 일치한다. # (기존: sell_qty 는 '현재 잔량'×sell_pct, remaining_pct 는 '원본' 기준 # 차감 → 분할이 반복될수록 회계가 벌어졌다.) # 원본 수량은 ActiveTrade 의 remaining_pct 로 역산한다. sell_pct = getattr(exit_signal, "sell_pct", 1.0) original_qty = quantity if 0 < remaining_pct < 1.0: original_qty = max(quantity, int(round(quantity / remaining_pct))) if sell_pct >= 1.0: sell_qty = quantity # 전량 매도(스윙 보유분 전체) elif quantity <= 3: # 3주 이하: 분할 매도 비효율 → 전량 매도 sell_qty = quantity logger.info(f"[SwingExit] {stock_code}: {quantity}주 소량 → 전량매도 (분할 비효율)") else: sell_qty = min(max(int(original_qty * sell_pct), 1), quantity) if sell_qty <= 0: continue # ── [C-1 6회차] 국내 매도 직전 KIS 실보유로 수량 상한 ──────────────── # 손절·익절·시간손절·폴백 모든 국내 시장가 매도: 상한 = min(주문가능, 보유) − DT # 인메모리 보유분. Portfolio 수량(sync 전 낡은 값)이 부풀어 있어도 DT 물량은 # 팔 수 없다. 조회 실패면 보류, 상한 0 이면 주문하지 않는다. _pre_hldg = None if domestic: if _kis_snapshot is None: _kis_snapshot = await _presell_kis_holding_v2(stock_code) if _kis_snapshot is None: continue _kis_hldg, _kis_sellable = _kis_snapshot _cap = max(0, min(_kis_sellable, _kis_hldg) - max(0, int(_dt_qty or 0))) if _cap <= 0: logger.warning( f"[SwingExit] {stock_code}: KIS 기준 스윙 매도 가능 0 (보유 {_kis_hldg}, " f"주문가능 {_kis_sellable}, DT {_dt_qty}) — 주문 안 함" ) continue if _cap < quantity: logger.warning( f"[SwingExit] {stock_code}: 장부 스윙 {quantity}주 > KIS 기준 {_cap}주 — " f"KIS 기준으로 상한" ) quantity = _cap total_qty = _kis_hldg if sell_qty > quantity: sell_qty = quantity _pre_hldg = _kis_hldg # [M-4 2026-10-02] 주문 전송 ~ TradeHistory·Portfolio·ActiveTrade·loss_tracker 반영 # 전체를 asyncio.shield 로 보호한다(DT _execute_exit_impl 과 같은 패턴). 스케줄러 # 종료·취소가 safe_place_order 대기 중에 들어오면 "브로커는 체결, 장부는 미반영" → # 다음 기동 후 같은 물량 재매도가 날 수 있었다. 취소는 이 루프에만 전파되고 # shield 된 작업은 끝까지 진행된다. async def _sell_and_record_v2(): # ── KIS API 매도 주문 ───────────────────────────────────── logger.info( f"[SwingExit] 청산 신호 → {stock_code} {stock_name} " f"수량={sell_qty} 이유={exit_signal.reason}" ) # [C1] 분산락+세마포어 보호 주문 from app.services.trade_executor import safe_place_order order_resp = await safe_place_order( stock_code=stock_code, order_type="SELL", quantity=sell_qty, price=None if domestic else latest_price, is_domestic=domestic, exchange_code=market or "NASD", ) # [M-2 2026-10-02 5회차] 실패를 msg_cd 로 분류한다. 결과 불명(전송 후 응답 유실)을 # 거부와 똑같이 continue 하면, 60초 뒤 sync 전 낡은 Portfolio 수량 − DT 로 # 재매도해 (체결됐을 경우) DT 공존 종목에서 과다매도가 났다. # - unknown: 확인 대기 등록 → 다음 재매도 전 KIS 잔고로 수량 상한 재산정 # - retry : 전송 전 실패·업무 거부 — 기존처럼 다음 주기 재시도 _outcome = _classify_swing_sell_response_v2(order_resp) if _outcome != "accepted": err = (order_resp or {}).get("msg1", "unknown") if isinstance(order_resp, dict) else "no response" _mc = (order_resp or {}).get("msg_cd") if isinstance(order_resp, dict) else None if _outcome == "unknown" and not domestic: # 해외 잔고 확인 경로는 아직 없다(US_TRADING_ENABLED=False). 기존 동작 유지. logger.error( f"[SwingExit] 🚨 해외 매도 주문 결과 불명 ({stock_code}, {sell_qty}주, msg_cd={_mc}): " f"{err} — 해외 잔고 확인 미지원, 수동 확인 필요" ) elif _outcome == "unknown": _pending_unknown_sells[stock_code] = { "qty": int(sell_qty), "pre_hldg": int(_pre_hldg if _pre_hldg is not None else total_qty), "since": time.time(), "price": float(latest_price), "avg_price": float(avg_price), "swing_qty": int(quantity), "sell_pct": float(sell_pct), "remaining_pct": float(remaining_pct), "original_qty": int(original_qty), "strategy": _strategy, "filter_not_dt": bool(_at_filter_not_dt), "had_active_trade": active_trade is not None, "stock_name": stock_name, "reason": exit_signal.reason, } _trigger_portfolio_sync_v2() logger.error( f"[SwingExit] 🚨 매도 주문 결과 불명 ({stock_code}, {sell_qty}주, msg_cd={_mc}): " f"{err} — 확인 대기 등록, 다음 주기 KIS 잔고로 체결분 대조·장부 반영" ) else: logger.error( f"[SwingExit] 매도 주문 실패 ({stock_code}, msg_cd={_mc}): {err} — 다음 주기 재시도" ) return order_id = order_resp.get("output", {}).get("ODNO", "") profit_loss = (latest_price - avg_price) * sell_qty profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100 # ── DB 기록 (db_task_queue 경유) ────────────────────────── await enqueue_trade_history({ "stock_code": stock_code, "stock_name": stock_name, "order_type": "SELL", "order_price": latest_price, "order_quantity": sell_qty, "executed_price": latest_price, "executed_quantity": sell_qty, "total_amount": latest_price * sell_qty, "status": "COMPLETED", "order_id": order_id, "exit_reason": exit_signal.reason, "net_profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "strategy_type": "swing", }) _recent_exits[stock_code] = time.time() # 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시) from app.services.portfolio_cache_bridge import reflect_sell # remaining_qty 는 Portfolio 행(스윙+DT 합산) 기준이어야 한다 — # 스윙 보유분(quantity)만 쓰면 DT 수량이 사라진다. await reflect_sell( stock_code, sell_qty, latest_price, remaining_qty=total_qty - sell_qty ) # 전량 청산이면 ActiveTrade 종료, 분할 매도면 remaining_pct 갱신 if sell_qty >= quantity: # strategy_name 을 비우면 db_worker 가 '가장 최근 active 행'을 닫는다 # → 같은 종목에 DT 행이 있으면 그쪽이 닫히고 스윙 행은 영구 잔존. if _at_filter_not_dt: # 전략명 NULL 인 비-DT 행은 이름으로 닫을 수 없다 → # strategy_name_not 필터로 비-DT 최신 행을 직접 겨냥. from app.services.db_worker import ( DT_STRATEGY_NAMES as _DTN_CL, enqueue_db_write, ) await enqueue_db_write({ "type": "close_active_trade", "data": { "stock_code": stock_code, "strategy_name_not": list(_DTN_CL), }, }) else: await enqueue_close_active_trade( stock_code, strategy_name=_strategy or "swing" ) else: # 분할 매도 후 remaining_pct 갱신 (반복 트리거 방지) # [H1 2026-10-02] 잔량 비율만 기록하면 홀수 수량에서 내림 매도 후 # 비율이 게이트(>0.50 / >0.75) 위에 남아 쿨다운 뒤 같은 단계가 # 재발동했다(7주→1주, 5주→전량). 단계값(이전 − sell_pct)을 상한으로 # 기록한다. 매도 주수 자체는 위의 내림 공식 그대로(과매도 방지). from app.services.sizing_core import remaining_pct_after_partial_sell new_remaining = remaining_pct_after_partial_sell( remaining_pct, sell_pct, quantity - sell_qty, original_qty ) from app.services.db_worker import enqueue_active_trade_upsert _at_data = {"stock_code": stock_code} if _at_filter_not_dt: # active_trade 가 존재하는데 전략명이 NULL 인 경우에만 True → # 아래 `active_trade is None` 신규생성 분기와 상호배타적이므로 # strategy_name 과 strategy_name_not 이 동시에 실리지 않는다. from app.services.db_worker import DT_STRATEGY_NAMES as _DTN_UP _at_data["strategy_name_not"] = list(_DTN_UP) else: _at_data["strategy_name"] = _strategy or "swing" _at_data.update({ "remaining_pct": new_remaining, # 잔량도 함께 갱신 — 누락 시 재기동 복원이 원래 수량으로 되살아나 # 보유량보다 많이 매도한다(2026-09-23 DT LG씨엔에스 사례와 동일 패턴) "quantity": quantity - sell_qty, "highest_since_entry": latest_price if latest_price > (trailing_high or 0) else trailing_high, }) if active_trade is None: # [FIX] ActiveTrade 행이 없는 포지션(동기화 유입 등)은 upsert가 # NOT NULL(quantity/entry_price) 위반으로 INSERT 실패 → remaining_pct # 미저장 → 다음 사이클에 50% 매도 재실행(2026-09-22 두산에너빌리티). # 신규 생성에 필요한 필수 키를 함께 전달한다. _at_data.update({ "stock_name": stock_name, "quantity": quantity - sell_qty, "entry_price": avg_price, "current_price": latest_price, "strategy_name": "swing", "status": "active", "entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)", }) await enqueue_active_trade_upsert(_at_data) # LossTracker 업데이트 — 전량매도 시에만 (분할매도 이중 카운트 방지) if sell_qty >= quantity: # [H-1 2026-10-02] 전량 청산 → 이 포지션의 실시세 캐시 폐기 # (재매수 포지션에 예전 보유분 가격이 대체가로 쓰이지 않게) _last_live_prices.pop(stock_code, None) _last_atr.pop(stock_code, None) is_loss = profit_loss < 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) # [2026-09-28] 전략 간 당일 쿨다운 — 스윙이 손절한 종목을 같은 날 # 데이트레이딩이 다시 사는 것을 막는다(09-04 009420 -14% 손절 직후 # DT 워치리스트 잔류 확인). 조회는 daytrading/entry_service 의 # is_closed_today() 경로를 그대로 쓴다. try: from app.services.daytrading.position_manager import ( day_trading_state as _dts_cd, ) # closed_today 변경은 trade_lock 보유가 규약(position_manager 참조) async with _dts_cd.trade_lock: _dts_cd.closed_today.add(stock_code) # [M4 2026-09-28 / F9] 영속화 — 이 경로만 저장이 빠져 있어 # 재기동하면 "스윙이 손절한 종목을 같은 날 DT 가 재매수"가 # 되살아났다(전략 간 당일 쿨다운이 무효화). 락 해제 후 호출한다 # (Redis I/O 로 trade_lock 을 잡지 않는다). await _dts_cd.save_daily_state() except Exception as _cd_err: logger.warning( f"[SwingExit] DT 당일 재진입 차단 등록 실패 ({stock_code}): {_cd_err}" ) executed_exits.append({ "stock_code": stock_code, "stock_name": stock_name, "sell_quantity": sell_qty, "price": latest_price, "profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "reason": exit_signal.reason, }) logger.info( f"[SwingExit] ✅ 청산 완료: {stock_code} {stock_name} " f"수익률={profit_loss_rate:.1f}% 이유={exit_signal.reason}" ) await asyncio.shield(_sell_and_record_v2()) except Exception as e: logger.error(f"[SwingExit] 종목 처리 오류 ({holding.stock_code}): {e}", exc_info=True) except Exception as e: logger.error(f"[SwingExit] 전체 오류: {e}", exc_info=True) return executed_exits