"""데이트레이딩 청산 서비스 당일 포지션의 청산 조건 체크 및 실행: - 5분봉 ATR 기반 손절/익절 (day_trading_strategy.check_day_trade_exit 재활용) - 15:20 EOD 강제 청산 (CLAUDE.md 7.3: 당일 청산 원칙) - 일일 손실 한도(-3%) 초과 시 즉시 전체 청산 - 분할 매도 지원: 목표가 달성 시 50% 매도, 잔량 트레일링스탑 청산 우선순위: 1. 일일 손실 한도 초과 → 전체 강제 청산 2. EOD 15:20 도달 → 전체 강제 청산 3. 개별 포지션 체크: - 손절: ATR 기반 또는 하드스탑 - 시간 손절: 진입 30분 경과 후 언제든 -0.3% 미만 - 과매수 청산: RSI >= 70 - 분할 익절: 목표가 +2R 달성 시 50% 매도 아키텍처 규칙: - CLAUDE.md 3.1: DB I/O 금지 — 인메모리 상태만 수정, 체결 후 db_task_queue 경유 - loss_tracker 연동: 전량 매도 시 record_trade_result_safe() + save_to_db() 호출 """ import asyncio import logging import time from datetime import datetime, timedelta import pytz from app.core.config import settings from app.core.log_throttle import warn_throttled from app.core.market_schedule import is_eod_flat_time, is_market_open from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( parse_minute_candles, aggregate_to_5min, check_day_trade_exit, is_daily_loss_exceeded, is_time_stop_exit_reason, ) from app.services.daytrading.position_manager import day_trading_state from app.services.daytrading import fill_reconciler from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade from app.services.loss_tracker import loss_tracker from app.services.trade_cost_core import net_realized_pnl logger = logging.getLogger(__name__) # 한국 표준시(KST) 타임존 _KST = pytz.timezone("Asia/Seoul") # [M1] 전일(stale) 포지션 강제청산으로 발생한 실현손익 누적 (당일 집계에서 제외된 금액). # 당일 손실 한도 판단에는 쓰지 않고 추적/로그 용도로만 사용한다. _stale_realized_pnl: float = 0.0 # [H3/H-A 2026-10-02] 이번 프로세스에서 stale 매도 **주문이 접수된**(rt_cd=="0") (날짜, 종목). # 재주문 금지용. 실패는 여기 넣지 않는다 — 아래 _stale_sell_failures 로 제한 재시도한다. _stale_sell_attempted: set[tuple[str, str]] = set() # [H-A] stale 매도 실패 이력 {(날짜, 종목): (실패 횟수, 마지막 실패 monotonic)} _stale_sell_failures: dict[tuple[str, str], tuple[int, float]] = {} # [H-A/H-B] 종목별 마지막 청산 주문 결과: "accepted" | "rejected" | "unknown" _last_exit_order_outcome: dict[str, str] = {} # [M-4] stale 직전 시도의 주문 수량·시각 (불명 주문 매칭 범위) _stale_last_qty: dict[tuple[str, str], int] = {} _stale_last_since: dict[tuple[str, str], datetime] = {} # [L-1] 보유 0 정리를 이미 큐잉한 (날짜, 종목) _stale_cleaned: set[tuple[str, str]] = set() # [H4 2026-10-02] 청산 체크 처리량·상한 — settings 로 이관(값 동일): # DAY_TRADE_EXIT_CHECK_CONCURRENCY(4) / DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC(15) / # DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC(45) / DAY_TRADE_TOTAL_ASSETS_REFRESH_SEC(300). # 종전에는 포지션마다 당일 전체 분봉을 **순차** 조회해(오후 종목당 10~12페이지 × 약 3초) # 포지션 몇 개만으로 스케줄러 _guard(STEP_TIMEOUT=180)를 넘겨 청산 체크 전체가 취소됐다. # 손절선 판정은 현재가 1회 조회로 먼저 끝내고, 분봉 기반 판정은 제한 동시성 + 포지션별 # 상한으로 돌린다. 동시성은 entry_service 선조회(8)보다 낮게 잡아 KIS 세마포어(8)를 # 진입 스캔과 나눠 쓴다. # 최악치(기본값, N=DAY_TRADE_MAX_POSITIONS=5 → ceil(5/4)=2 웨이브): # 총자산 조회 15 + 손절 선판정 2×(15+5)=40 + 분봉 판정 2×45=90 = 145초 # + close_stale_positions(잔고 15 + 종목당 현재가·주문) — stale 이 있으면 180초에 # 근접할 수 있다. 초과 시 _guard 가 취소하지만 주문~반영은 shield 로 완주한다. _total_assets_refreshed_at: float = 0.0 def get_stale_realized_pnl() -> float: """전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액).""" return _stale_realized_pnl def _dt_sell_tax_rate(pos: dict) -> float: """DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일).""" market = str((pos or {}).get("market") or settings.DAY_TRADE_DEFAULT_TAX_MARKET or "KOSPI").upper() if market == "KOSDAQ": return float(settings.KR_SELL_TAX_RATE_KOSDAQ) return float(settings.KR_SELL_TAX_RATE_KOSPI) def dt_net_pnl(entry_price: float, exit_price: float, quantity: int, pos: dict) -> dict: """[2026-10-01] DT 실현손익(비용 차감). 스윙 집행 경로와 같은 비용 정의(trade_cost_core). 매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(KR_SELL_TAX_RATE_*)를 뺀다. 반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate """ return net_realized_pnl( entry_price, exit_price, quantity, float(settings.KR_COMMISSION_RATE), _dt_sell_tax_rate(pos), ) def _fallback_stop_pct() -> float: """[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율. 기존 하드코딩 0.06(-6%)은 전략 하드캡(ORB 3%, VWAP/RSI_BB 3.5%)의 2배로 느슨했다. 가장 보수적(타이트)한 하드캡을 사용해 폴백이 실제 전략보다 헐거워지지 않게 한다. position_manager.restore_from_db() 의 폴백(DAY_TRADE_STOP_LOSS_PCT)과도 일치. """ return min( settings.DAY_TRADE_STOP_LOSS_PCT, settings.DAY_TRADE_ORB_STOP_LOSS_PCT, settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT, ) def _fallback_stop_price(entry_price: float) -> float: """진입가 기준 폴백 손절가.""" return entry_price * (1 - _fallback_stop_pct()) def _fallback_target_price(entry_price: float, stop_loss: float) -> float: """진입가·손절가 기준 폴백 목표가 (DAY_TRADE_TARGET_MULTIPLIER 정합).""" risk = entry_price - stop_loss if risk <= 0: risk = entry_price * _fallback_stop_pct() return entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER def _kst_now() -> datetime: """현재 KST 시간을 timezone-naive datetime으로 반환. entry_time과 비교 시 timezone 불일치 방지를 위해 tzinfo 제거. Returns: datetime: 현재 KST 시간 (timezone-naive) """ return datetime.now(_KST).replace(tzinfo=None) # [2026-10-01] 진입 시각 신뢰 구간 (KST 시각). DT 진입은 정규장 안에서만 일어나므로 # 이 밖의 entry_time 은 기준 불일치(예: 과거 UTC 저장 행 — KST 09:05 → UTC 00:05)로 본다. _ENTRY_TIME_VALID_FROM = (9, 0) _ENTRY_TIME_VALID_TO = (15, 30) # 1분봉 라벨(stck_cntg_hour)이 "분 시작"인지 "분 종료"인지 응답 규약이 확정되지 않았다. # 라벨 >= 진입시각 + 60초 인 봉만 쓰면 두 해석 모두에서 봉 전체가 진입 이후 체결이다. _POST_ENTRY_CANDLE_LAG_SEC = 60 def _normalize_entry_time(entry_time) -> datetime | None: """entry_time 을 KST naive datetime 으로 정규화. 신뢰할 수 없으면 None. - 인메모리 진입(entry_service): `datetime.now(KST).replace(tzinfo=None)` (KST naive) - DB 복원(position_manager.restore_from_db): `ActiveTrade.created_at` (모델 기본값 now_kst = KST naive). 단 2026-09-23 이전 행은 UTC 로 저장된 것이 섞여 있다(timeutil 참조). """ if entry_time is None or not isinstance(entry_time, datetime): return None if entry_time.tzinfo is not None: entry_time = entry_time.astimezone(_KST).replace(tzinfo=None) hm = (entry_time.hour, entry_time.minute) if hm < _ENTRY_TIME_VALID_FROM or hm > _ENTRY_TIME_VALID_TO: return None if entry_time > _kst_now(): return None return entry_time def _candle_datetime(candle) -> datetime | None: """MinuteCandle(date=YYYYMMDD, time=HHMMSS, KST) → naive datetime. 형식 불량이면 None.""" d = getattr(candle, "date", "") or "" t = getattr(candle, "time", "") or "" if len(d) != 8 or len(t) < 4: return None try: return datetime( int(d[:4]), int(d[4:6]), int(d[6:8]), int(t[:2]), int(t[2:4]), int(t[4:6]) if len(t) >= 6 else 0, ) except (ValueError, TypeError): return None def _post_entry_candle_high(candles, entry_time) -> float | None: """진입 **이후에 시작한** 분봉들의 최고가. 산출 불가/해당 봉 없음이면 None (순수 함수). [2026-10-01 버그 수정] 예전에는 `max(c.high for c in candles_5m[-3:])` 로 최근 5분봉 3개의 고가를 그대로 트레일링 기준 고점에 넣었다. 진입 후 15분 이내에는 **진입 전 봉의 고가**가 섞여, 실제로 오르지 않았는데 +1% 트레일링 트리거가 켜지거나 `고점 − 1.5×ATR` 손절선이 부풀려졌다(운영 실측: 트레일링 6건이 "고점 +1.1~2.2%" 기록 후 −0.13~−0.81% 로 종료). 보수적 처리: - **진입이 속한 봉은 제외한다.** 봉 고가가 진입 전/후 어느 쪽 체결인지 알 수 없다. 진입 직후 구간의 고점은 30초 주기의 현재가 관측과 진입 시 초기값(=진입가)으로만 잡는다 (고점을 과소 추정 → 트레일링이 늦게 켜질 뿐, 하드 손절·시간손절은 영향 없음). - 5분봉 대신 1분봉을 쓰고, 라벨 >= 진입시각+60초 인 봉만 인정한다(라벨 규약 무관 안전). - 날짜까지 포함한 datetime 으로 비교해 전일 캔들 혼입(CLAUDE.md §7)을 차단한다. 날짜·시각이 비어 있거나 형식 불량인 봉은 위치를 알 수 없으므로 제외한다. - entry_time 이 없거나 신뢰할 수 없으면 None → 호출부는 현재가만 쓴다. """ entry = _normalize_entry_time(entry_time) if entry is None: return None threshold = entry + timedelta(seconds=_POST_ENTRY_CANDLE_LAG_SEC) best: float | None = None for c in candles or []: cdt = _candle_datetime(c) if cdt is None or cdt < threshold: continue h = float(getattr(c, "high", 0) or 0) if h > 0 and (best is None or h > best): best = h return best async def check_and_exit_positions() -> list[dict]: """활성 데이트레이딩 포지션의 청산 조건 체크 및 실행. 인메모리 positions 순회 → 5분봉 ATR 기반 청산 판단. 스케줄러에서 30초 주기로 호출. 체크 순서: 1. 일일 손실 한도 초과 → 전체 강제 청산 2. EOD 15:20 → 전체 강제 청산 3. 개별 포지션별 check_day_trade_exit() 호출 Returns: list[dict]: 청산 실행 결과 목록 """ day_trading_state.reset_if_new_day() exits = [] if not day_trading_state.positions: # 인메모리가 비어도 DB에 전일 미청산 포지션이 남아 있을 수 있다(자정 reset 이후) return await close_stale_positions() # [L5 2026-10-02] 일일 손실한도 분모 확보 — last_total_assets 는 scan_and_enter 에서만 # 갱신돼, 진입 스캔이 돌지 않는 구간(MDD 차단·마감 후·재기동 직후)에는 0 이었다. # is_daily_loss_exceeded() 는 total_assets<=0 이면 무조건 False → 청산 측 -3% 한도 무력화. await _ensure_total_assets_for_exit_impl() # 일일 손실 한도 초과 시 전체 강제 청산 # [S5] 미실현 손익도 포함하여 일일 손실 한도 판단 unrealized_pnl = 0.0 for _code, _pos in day_trading_state.positions.items(): _entry = _pos.get("entry_price", 0) _qty = _pos.get("quantity", 0) _cur = _pos.get("current_price", _entry) if _entry > 0 and _qty > 0: unrealized_pnl += (_cur - _entry) * _qty total_daily_pnl = day_trading_state.daily_pnl + unrealized_pnl if is_daily_loss_exceeded(day_trading_state.last_total_assets, total_daily_pnl): logger.warning( f"[DayExit] 일일 손실 한도 초과 → 전체 강제 청산 " f"(실현={day_trading_state.daily_pnl:.0f}, 미실현={unrealized_pnl:.0f})" ) return await force_close_all(reason="일일 손실 한도 초과") # EOD 강제 청산 (15:20) if is_eod_flat_time(): logger.info("[DayExit] EOD (15:20) → 전체 강제 청산") return await force_close_all(reason="EOD 강제 청산") # [안전장치 2026-09-23] 전일 미청산 포지션 즉시 청산. # 데이트레이딩은 당일 청산이 원칙인데 15:20 EOD가 실패/누락되면(2026-09-22 LG씨엔에스) # 포지션이 하룻밤 갭 리스크에 노출된 채 다음 날 일반 청산 로직을 타게 된다. stale_exits = await close_stale_positions() if stale_exits: exits.extend(stale_exits) if not day_trading_state.positions: return exits # [H4 2026-10-02] 1단계 — 손절선 선판정(현재가 1회 조회, 제한 동시성). # 분봉 전체 조회가 느려도 손절은 먼저 집행된다(§8.1-11 청산 우선). codes_snapshot = list(day_trading_state.positions.keys()) fast = await _run_exit_checks_bounded_impl( codes_snapshot, _fast_stop_check_impl, float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0, "손절 선판정", ) handled: set[str] = set() for code, res in fast.items(): if not res: continue _done, _exit = res if _done: handled.add(code) if _exit: exits.append(_exit) # 2단계 — 분봉 기반 전체 판정(트레일링·시간손절·목표가·RSI). 포지션별 상한 + 제한 동시성. remaining_codes = [ c for c in codes_snapshot if c not in handled and c in day_trading_state.positions ] full = await _run_exit_checks_bounded_impl( remaining_codes, _check_single_position, float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC), "청산 판정", ) for code in remaining_codes: if full.get(code): exits.append(full[code]) return exits async def check_stops_only_v2() -> list[dict]: """[M4 2026-10-02] 손절 전용 체크 — 09:00~09:30 ORB 수집 구간에서 호출. ORB 분기가 `continue` 로 청산 체크를 건너뛰어, 그 30분 동안 전일 미청산 포지션과 재기동 복원 포지션의 손절이 전혀 판정되지 않았다. 이 함수는 진입·익절 규칙을 건드리지 않고 **전일 포지션 청산 + 손절선 선판정**만 수행한다. """ day_trading_state.reset_if_new_day() exits: list[dict] = list(await close_stale_positions() or []) codes = list(day_trading_state.positions.keys()) if not codes: return exits fast = await _run_exit_checks_bounded_impl( codes, _fast_stop_check_impl, float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + 5.0, "ORB 구간 손절 판정", ) for res in fast.values(): if res and res[1]: exits.append(res[1]) return exits async def _run_exit_checks_bounded_impl(codes, fn, timeout: float, label: str) -> dict: """[H4] 포지션별 청산 판정을 제한 동시성 + 포지션별 상한으로 실행. {code: 결과}. 주문~상태 반영은 _execute_exit_impl 이 shield 하므로, 상한 초과로 판정 코루틴이 취소돼도 이미 전송된 주문의 반영은 끝까지 진행된다. """ out: dict = {} if not codes: return out sem = asyncio.Semaphore(max(1, int(settings.DAY_TRADE_EXIT_CHECK_CONCURRENCY))) async def _one(code: str) -> None: async with sem: try: out[code] = await asyncio.wait_for(fn(code), timeout=timeout) except asyncio.TimeoutError: logger.warning( f"[DayExit] ⏱️ {label} {timeout:.0f}초 초과 ({code}) — 이번 사이클 스킵, " f"다음 사이클 재판정" ) except Exception as e: logger.error(f"[DayExit] {label} 오류 ({code}): {e}", exc_info=True) await asyncio.gather(*(_one(c) for c in codes)) return out def _effective_stop_loss(pos: dict) -> float: """포지션의 실효 손절가 — _check_single_position 과 같은 규칙(없거나 0 이하면 폴백).""" entry_price = float(pos.get("entry_price", 0) or 0) _sl_raw = pos.get("stop_loss") if _sl_raw is not None and float(_sl_raw) > 0: return float(_sl_raw) return _fallback_stop_price(entry_price) async def _fast_stop_check_impl(stock_code: str) -> tuple[bool, dict | None]: """[H4] 현재가 1회 조회로 손절선(하드/폴백)만 판정. (처리됨 여부, 청산 결과). check_day_trade_exit 의 '손절' 조건(현재가 ≤ 손절가)과 같은 판정이다 — 입력 가격만 분봉 종가 대신 현재가 시세다. 미도달·조회 실패면 (False, None) → 분봉 기반 판정으로 넘긴다. """ pos = day_trading_state.get_position(stock_code) if not pos: return True, None # [H-B] 청산 주문 진행 중 → '처리됨'으로 보고 2단계 판정도 건너뛰게 한다 if day_trading_state.is_exit_in_progress(stock_code): return True, None entry_price = float(pos.get("entry_price", 0) or 0) if entry_price <= 0 or int(pos.get("quantity", 0) or 0) <= 0: return False, None stop_loss = _effective_stop_loss(pos) current_price = 0.0 try: resp = await asyncio.wait_for( korea_investment_api.get_current_price(stock_code), timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC), ) current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0 except asyncio.TimeoutError: logger.warning( f"[DayExit] 손절 선판정 현재가 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 ({stock_code}) " f"— 분봉 판정으로 넘김" ) return False, None except Exception as e: logger.warning(f"[DayExit] 손절 선판정 현재가 조회 실패 ({stock_code}): {e} — 분봉 판정으로 넘김") return False, None if current_price <= 0: return False, None async with day_trading_state.trade_lock: if stock_code in day_trading_state.positions: day_trading_state.positions[stock_code]["current_price"] = current_price if current_price > stop_loss: return False, None loss_pct = (current_price - entry_price) / entry_price * 100 reason = ( f"손절 {loss_pct:.1f}% (현재={current_price:.0f} ≤ 손절가={stop_loss:.0f}, 현재가 선판정)" ) logger.warning(f"[DayExit] ⚠️ {stock_code} {reason}") try: result = await _execute_exit(stock_code, pos, current_price, reason, sell_pct=1.0) except Exception as e: logger.error(f"[DayExit] 손절 선판정 주문 실행 실패 ({stock_code}): {e}", exc_info=True) result = None # 주문 실패여도 이번 사이클 분봉 판정에서 같은 손절을 재주문하지 않는다(다음 사이클 재시도). return True, result async def _ensure_total_assets_for_exit_impl() -> float: """[L5] 청산 측 일일 손실한도 분모(총자산) 확보. 순서: 최근 값이 유효(>0, 갱신 주기 이내)면 그대로 → KIS 잔고 조회 → 마지막 값 → Redis 일일 안전장치 저장분(`daytrading:daily:{오늘}`.last_total_assets) → 없으면 warning. """ global _total_assets_refreshed_at now = time.monotonic() cur = float(day_trading_state.last_total_assets or 0) if cur > 0 and _total_assets_refreshed_at and (now - _total_assets_refreshed_at) < float(settings.DAY_TRADE_TOTAL_ASSETS_REFRESH_SEC): return cur fresh = 0.0 try: bal = await asyncio.wait_for( korea_investment_api.get_balance(), timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC), ) summary = ((bal or {}).get("output2") or [{}])[0] if isinstance(bal, dict) else {} fresh = float((summary or {}).get("tot_evlu_amt", 0) or 0) except asyncio.TimeoutError: logger.warning(f"[DayExit] 총자산 조회 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 — 이전 값 사용") except Exception as e: logger.warning(f"[DayExit] 총자산 조회 실패 — 이전 값 사용: {e}") # 실패해도 다음 갱신 주기까지 재조회하지 않는다(매 사이클 실패 호출 방지) _total_assets_refreshed_at = now if fresh > 0: day_trading_state.last_total_assets = fresh return fresh if cur > 0: return cur restored = 0.0 try: from app.core.redis import get_json from app.services.daytrading.position_manager import _today_kst data = await get_json(f"daytrading:daily:{_today_kst()}") if data and str(data.get("date") or "") == _today_kst().isoformat(): restored = float(data.get("last_total_assets", 0) or 0) except Exception as e: logger.warning(f"[DayExit] 총자산 Redis 복원 실패: {e}") if restored > 0: day_trading_state.last_total_assets = restored logger.warning(f"[DayExit] 총자산 KIS 미확보 → Redis 저장값 {restored:,.0f}원 사용") return restored # 다음 사이클에 즉시 재시도하도록 타임스탬프를 되돌린다(값이 아예 없는 상태) _total_assets_refreshed_at = 0.0 warn_throttled( logger, "dt_exit:total_assets_missing", f"[DayExit] ⚠️ 총자산 미확보 — 청산 측 일일 손실한도" f"({float(settings.DAY_TRADE_DAILY_LOSS_LIMIT) * 100:+.1f}%) 판정 불가 상태 " f"(KIS·이전 값·Redis 모두 없음)", ) return 0.0 async def _check_single_position(stock_code: str) -> dict | None: """단일 포지션의 청산 조건 체크. 5분봉 데이터를 조회하여 check_day_trade_exit()로 청산 신호를 판단. 최고가(highest_price)를 갱신하여 트레일링스탑 정확도 향상. Args: stock_code: 종목 코드 Returns: dict | None: 청산 실행 결과 또는 None (청산 조건 미충족) """ pos = day_trading_state.get_position(stock_code) if not pos: return None # [H-B] 청산 주문 진행 중이면 판정하지 않는다(인메모리 수량이 아직 반영 전) if day_trading_state.is_exit_in_progress(stock_code): return None entry_price = pos.get("entry_price", 0) # [M7] stop_loss / target_price 는 None 뿐 아니라 0 으로 저장될 수 있다. # `or` 패턴은 0 도 폴백으로 넘기지만 의도가 모호하므로 명시적으로 None/비정상값을 검사한다. # (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨) _sl_raw = pos.get("stop_loss") stop_loss = float(_sl_raw) if (_sl_raw is not None and float(_sl_raw) > 0) else _fallback_stop_price(entry_price) _tp_raw = pos.get("target_price") target_price = ( float(_tp_raw) if (_tp_raw is not None and float(_tp_raw) > 0) else _fallback_target_price(entry_price, stop_loss) ) highest_price = pos.get("highest_price", entry_price) entry_time = pos.get("entry_time") # [H4] try 범위를 "분봉 조회 + 지표/신호 계산"까지로 한정한다. # 예전 코드는 _execute_exit 까지 하나의 try 안에 있고 로그가 debug 였기 때문에 # 분봉 조회 실패 시 손절·트레일링·시간손절이 전부 미적용된 채 15:20 까지 방치되고, # 주문 실패조차 로그에 보이지 않았다. exit_signal = None current_price = 0.0 try: raw = await korea_investment_api.get_minute_chart_full_day(stock_code) candles = parse_minute_candles(raw) candles_5m = aggregate_to_5min(candles) except Exception as e: logger.warning(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e} — 폴백 손절만 판정") return await _check_fallback_stop_loss(stock_code, pos, stop_loss) if not candles_5m or len(candles_5m) < 5: # 분봉이 부족하면 ATR/RSI 기반 판정이 불가 → 손절선만 지킨다(방치 금지) logger.warning( f"[DayExit] 5분봉 부족 ({stock_code}): {len(candles_5m or [])}개 — 폴백 손절만 판정" ) return await _check_fallback_stop_loss(stock_code, pos, stop_loss) try: current_price = float(candles_5m[-1].close) # highest_price 갱신 — 진입 이후에 시작한 1분봉 고가만 반영 (30초 주기 보완). # [2026-10-01] 예전 `candles_5m[-3:]` 는 진입 전 봉 고가가 섞여 트레일링 고점을 오염시켰다. # entry_time 없음/불신 → None → 현재가만 사용(고점 과대 추정보다 과소 추정이 안전). # 기존 highest_price 와 max → 단조 증가 유지. candle_high = _post_entry_candle_high(candles, entry_time) if candle_high is None and entry_time is not None and _normalize_entry_time(entry_time) is None: logger.warning( f"[DayExit] entry_time 불신 ({stock_code}, entry_time={entry_time}) " f"— 트레일링 고점은 현재가만으로 갱신" ) new_highest = max(current_price, candle_high or 0.0, highest_price) async with day_trading_state.trade_lock: if stock_code in day_trading_state.positions: if new_highest > highest_price: day_trading_state.positions[stock_code]["highest_price"] = new_highest # [S5] 미실현 손익 계산용 현재가 갱신 day_trading_state.positions[stock_code]["current_price"] = current_price if new_highest > highest_price: highest_price = new_highest # 청산 신호 체크 current_time = _kst_now().time() exit_signal = check_day_trade_exit( entry_price=entry_price, current_price=current_price, stop_loss=stop_loss, target_price=target_price, highest_since_entry=highest_price, candles_5min=candles_5m, current_time=current_time, entry_time=entry_time, ) except Exception as e: logger.warning(f"[DayExit] 청산 지표 계산 실패 ({stock_code}): {e} — 폴백 손절만 판정") return await _check_fallback_stop_loss(stock_code, pos, stop_loss) if not exit_signal or current_price <= 0: return None # 주문 실행은 try 밖(별도 try) — 실패를 조회 실패와 구분해 error 로 남긴다. try: return await _execute_exit( stock_code, pos, current_price, exit_signal.reason, sell_pct=exit_signal.sell_pct, ) except Exception as e: logger.error(f"[DayExit] 청산 주문 실행 실패 ({stock_code}): {e}", exc_info=True) return None async def _check_fallback_stop_loss( stock_code: str, pos: dict, stop_loss: float, ) -> dict | None: """[H4] 분봉 미확보 시 최소 안전장치 — 손절선 이탈만 판정해 전량 청산. swing/exit_service.py 의 고정비율 폴백 손절과 같은 취지. 분봉이 없으면 트레일링/시간손절/RSI 청산은 판정할 수 없으므로 "손절선"만 지킨다. 현재가는 KIS 현재가 조회 → 인메모리 최신가(current_price) 순으로 확보하고, 둘 다 실패하면 logger.error 로 남긴다(손절 미적용을 침묵시키지 않는다). Args: stock_code: 종목 코드 pos: 포지션 정보 stop_loss: 적용할 손절가 (0 이하이면 진입가 기준 폴백 산출) Returns: dict | None: 청산 결과 또는 None (손절 미도달·판정 불가) """ entry_price = float(pos.get("entry_price", 0) or 0) if entry_price <= 0 or int(pos.get("quantity", 0) or 0) <= 0: return None if day_trading_state.is_exit_in_progress(stock_code): # [H-B] return None if stop_loss <= 0: stop_loss = _fallback_stop_price(entry_price) current_price = 0.0 try: resp = await korea_investment_api.get_current_price(stock_code) current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0 except Exception as e: logger.warning(f"[DayExit] 폴백 손절 현재가 조회 실패 ({stock_code}): {e}") if current_price <= 0: current_price = float(pos.get("current_price", 0) or 0) if current_price <= 0: logger.error( f"[DayExit] 폴백 손절 현재가 확보 실패 ({stock_code}) — 손절 미적용 상태, 수동 확인 필요" ) return None async with day_trading_state.trade_lock: if stock_code in day_trading_state.positions: day_trading_state.positions[stock_code]["current_price"] = current_price if current_price > stop_loss: return None loss_rate = (current_price - entry_price) / entry_price * 100 logger.warning( f"[DayExit] ⚠️ 폴백 손절 발동 ({stock_code}): 현재가={current_price:.0f} " f"<= 손절={stop_loss:.0f} ({loss_rate:.2f}%) — 분봉 미확보" ) try: return await _execute_exit( stock_code, pos, current_price, reason=f"폴백 손절(분봉 미확보) {loss_rate:.1f}%", sell_pct=1.0, ) except Exception as e: logger.error(f"[DayExit] 폴백 손절 주문 실패 ({stock_code}): {e}", exc_info=True) return None async def _execute_exit( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float = 1.0, ) -> dict | None: """실제 매도 주문 실행 및 상태 업데이트. 처리 흐름: 1. 매도 수량 계산 (분할 매도 시 최소 1주 보장) 2. safe_place_order()로 시장가 매도 (세마포어+분산락 보호) 3. 인메모리 상태 업데이트 (trade_lock 보호) 4. 전량 매도 시 loss_tracker 연동 5. DB 기록 (db_task_queue 경유) Args: stock_code: 종목 코드 pos: 포지션 정보 딕셔너리 price: 현재가 (매도 기준가) reason: 청산 사유 (로그 및 DB 기록용) sell_pct: 매도 비율 (1.0=전량, 0.5=50% 분할 매도) Returns: dict | None: 청산 결과 (stock_code, price, quantity, profit_loss 등) """ return await _execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale=False) async def _execute_exit_stale( stock_code: str, pos: dict, price: float, reason: str, ) -> dict | None: """[M1] 전일 미청산(stale) 포지션 전용 청산 경로. _execute_exit 과 동일하게 주문·상태반영·DB 기록을 수행하지만, 실현손익을 **당일** daily_pnl / loss_streak_today 에 가산하지 않는다. (전일 손실이 당일 손실 한도(-3%)를 오발동시켜 정상 포지션까지 전량 청산되고 신규 진입이 종일 차단되던 문제 — 2026-09-27 수정) """ return await _execute_exit_impl(stock_code, pos, price, reason, 1.0, is_stale=True) async def _execute_exit_impl( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, is_stale: bool, ) -> dict | None: """매도 주문 실행 + 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변). is_stale=True 이면 당일 손익 집계(update_pnl)를 건너뛴다. """ total_qty = pos.get("quantity", 0) if total_qty <= 0 or price <= 0: return None # [P1] 분할 매도 수량 계산 if sell_pct >= 1.0: quantity = total_qty else: quantity = max(1, int(total_qty * sell_pct)) if quantity >= total_qty: quantity = total_qty is_full_exit = (quantity >= total_qty) # [H-B 2026-10-02] 청산 진행 중 등록 — 같은 종목에 주문이 이미 나가 있으면 내지 않는다. # 판정 코루틴이 상한(wait_for)으로 취소돼도 shield 된 주문은 계속 진행되는데, 인메모리 # 수량은 finalize 전까지 그대로라 2단계/다음 사이클이 같은 물량을 다시 팔았다(4주 → 4+4). # 해제는 인메모리 반영 직후(정상) 또는 shield 작업의 finally(실패·불명)가 보장한다. async with day_trading_state.trade_lock: if stock_code in day_trading_state.exits_in_progress: warn_throttled( logger, "dt_exit:exit_in_progress", f"[DayExit] {stock_code} 청산 주문 진행 중 — 중복 매도 방지로 이번 요청 건너뜀 ({reason})", ) return None # [L-8] 판정 시점의 포지션 객체가 아직 그대로인지 — 그 사이 청산·교체됐으면 내지 않는다. # 전일(stale) 경로는 DB 에만 있는 행·수량 축소 사본을 넘기므로, 인메모리가 비었거나 # 같은 날짜 경계 밖(전일) 포지션일 때만 허용한다. _cur = day_trading_state.positions.get(stock_code) if is_stale: if _cur is not None and _cur is not pos and not _is_stale_position(_cur): logger.warning(f"[DayExit] {stock_code} 전일 청산 요청 — 당일 포지션과 겹쳐 건너뜀") return None elif _cur is not pos: logger.warning( f"[DayExit] {stock_code} 판정 이후 포지션이 청산/교체됨 — 주문하지 않는다 ({reason})" ) return None day_trading_state.exits_in_progress.add(stock_code) _tok = object() _exit_tokens[stock_code] = _tok # [H4 2026-10-02] 주문 전송 ~ 상태 반영 전체를 shield 한다. # 예전에는 finalize 만 shield 돼 있어, 청산 체크가 포지션별 wait_for·스케줄러 _guard # 로 취소되는 시점이 safe_place_order 대기 중이면 "브로커는 주문을 받았는데 응답을 # 잃어 인메모리는 그대로" → 다음 사이클이 같은 물량을 다시 판다(과다매도·스윙 물량 매도). return await asyncio.shield(_place_and_finalize_exit_guarded_impl( stock_code, pos, price, reason, sell_pct, quantity, total_qty, is_full_exit, is_stale, _tok, )) # 진행 중 표식의 소유 토큰 {code: token}. finalize 가 먼저 해제한 뒤 같은 종목의 새 청산이 # 표식을 다시 잡았을 때, 앞선 작업의 finally 가 새 표식을 지우지 못하게 한다. _exit_tokens: dict[str, object] = {} def _is_stale_position(pos: dict) -> bool: et = (pos or {}).get("entry_time") return hasattr(et, "date") and et.date() < _kst_now().date() def _release_exit_in_progress(stock_code: str, delay: float = 0.0, token=None) -> None: """[H-B] 청산 진행 중 표식 해제. delay>0 이면 그만큼 뒤에 해제(결과 불명 보류). token 을 주면 그 토큰이 아직 현재 소유자일 때만 해제한다(앞선 작업이 뒤 작업 표식을 지우는 경합 방지). finalize(현재 소유자 문맥)는 token 없이 즉시 해제한다. """ def _do() -> None: if token is not None and _exit_tokens.get(stock_code) is not token: return _exit_tokens.pop(stock_code, None) day_trading_state.exits_in_progress.discard(stock_code) if delay <= 0: _do() return try: asyncio.get_running_loop().call_later(delay, _do) except RuntimeError: _do() async def _place_and_finalize_exit_guarded_impl( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, quantity: int, total_qty: int, is_full_exit: bool, is_stale: bool, token=None, ) -> dict | None: """[H-B] 주문~반영 + 진행 중 표식 해제 보장 (shield 대상).""" _hold = 0.0 try: return await _place_and_finalize_exit_impl( stock_code, pos, price, reason, sell_pct, quantity, total_qty, is_full_exit, is_stale, ) finally: if _last_exit_order_outcome.get(stock_code) == "unknown": _hold = float(settings.DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC) logger.error( f"[DayExit] 🚨 {stock_code} 매도 주문 결과 불명(접수 미확인) — {_hold:.0f}초 동안 " f"같은 종목 재매도 보류. 다음 시도 직전에 당일 주문내역·실보유를 다시 확인한다" ) if token is None or _exit_tokens.get(stock_code) is token: _release_exit_in_progress(stock_code, _hold, token=token) # ── [H-1 2026-10-02] 주문 응답 분류 ───────────────────────────────────────────── # korea_investment_api.place_order 가 예외를 msg_cd 로 구분해 준다: # 전송 전 실패(요청이 나가지 않음) → 재매도 안전 = rejected # 전송 후 응답 유실(ReadTimeout·연결 끊김·5xx 재시도 초과) → 접수 여부 불명 = unknown # [L-2 r5] safe_place_order 가 붙이는 ORDER_LOCK_BUSY(분산락 미획득)·KIS_PRESEND_EXCEPTION # (place_order 호출 전 예외)도 전송 전이다 — trade_executor.SAFE_ORDER_MSG_* 와 같은 값. # (순환 import 를 피하려고 리터럴로 둔다. 값이 바뀌면 이 집합도 함께 바꿀 것.) _PRESEND_MSG_CDS = frozenset({ "TOKEN_UNAVAILABLE", "KIS_PRESEND_CONNECT", "KIS_PRESEND_CIRCUIT_OPEN", "KIS_PRESEND_EXCEPTION", "ORDER_LOCK_BUSY", }) _TRANSPORT_UNKNOWN_MSG_CD = "KIS_TRANSPORT_UNKNOWN" _RATE_LIMIT_MSG_CDS = frozenset({"EGW00201"}) # safe_place_order 가 분산락을 못 잡으면 주문을 보내지 않고 이 msg1 로 돌려준다(전송 전). _ORDER_LOCK_BUSY_MSG = "order_lock_busy" # [H-2] 결과 불명 주문이 아직 확인되지 않은 종목 # {(날짜, code): {"qty": 주문수량, "since": datetime, "total_qty": 주문 시 DT 보유}} # [L-1 r5] 키에 날짜를 넣고 날짜가 바뀌면 정리한다(_prune_exit_state_for_today). _unresolved_exit_unknown: dict[tuple[str, str], dict] = {} # [M-3 r5] 불명 확인용 조회(잔고·비DT 수량) 연속 실패 횟수 {(날짜, code): n}, 경보 보낸 키 _unknown_lookup_failures: dict[tuple[str, str], int] = {} _unknown_lookup_alerted: set[tuple[str, str]] = set() _alert_tasks: set = set() # 종목별 마지막 주문 응답 요약(분류·msg_cd·msg1) — stale 재시도 산정용 _last_exit_order_detail: dict[str, dict] = {} # 이 프로세스가 접수 확인한(finalize 한) DT 매도 주문번호 {(날짜, 정규화 ODNO)} — 불명 주문 # 매칭에서 제외한다(이미 반영한 우리 주문을 다른 불명 주문으로 다시 세지 않게). _finalized_sell_odnos: set[tuple[str, str]] = set() def _today_key() -> str: return _kst_now().date().isoformat() def _norm_odno(v) -> str: s_ = str(v or "").strip() return s_.lstrip("0") or ("0" if s_ else "") def _prune_exit_state_for_today() -> None: """[L-1 r5] 날짜가 바뀐 불명·ODNO·조회실패 상태를 버린다(전일 상태가 당일 판정에 새지 않게).""" today = _today_key() for d in (_unresolved_exit_unknown, _unknown_lookup_failures): for k in [k for k in d if k[0] != today]: d.pop(k, None) for coll in (_finalized_sell_odnos, _unknown_lookup_alerted): for k in [k for k in coll if k[0] != today]: coll.discard(k) def _classify_order_response(resp) -> str: """주문 응답 → "accepted" | "rejected" | "unknown". rejected = 전송 전 실패(msg_cd ∈ _PRESEND_MSG_CDS, 분산락 busy) 또는 KIS 업무 응답(rt_cd 가 "0"/"-1" 이 아닌 값). msg_cd 없는 rt_cd "-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 in _PRESEND_MSG_CDS or str(resp.get("msg1") or "") == _ORDER_LOCK_BUSY_MSG: return "rejected" if mc == _TRANSPORT_UNKNOWN_MSG_CD or rt == "-1": return "unknown" return "rejected" def _is_transient_order_failure(detail: dict | None) -> bool: """[M-3] 횟수에 세지 않는 일시 실패 — 레이트리밋·전송 전·전송 불명·응답 없음.""" if not detail: return True if detail.get("outcome") == "unknown": return True mc = str(detail.get("msg_cd") or "") m1 = str(detail.get("msg1") or "") return ( mc in _PRESEND_MSG_CDS or mc in _RATE_LIMIT_MSG_CDS or mc == _TRANSPORT_UNKNOWN_MSG_CD or any(c in m1 for c in _RATE_LIMIT_MSG_CDS) or m1 == _ORDER_LOCK_BUSY_MSG ) def _row_is_live_or_filled_sell(row: dict, stock_code: str) -> bool: """[M-4] 당일 주문내역 행이 '이 종목의 유효한 매도 접수'인지. 거부(rjct_qty>0)·취소(cncl_yn=Y)이면서 체결 0 인 행은 접수로 보지 않는다 (fill_reconciler.is_terminal_fill 과 같은 필드 기준). """ if not isinstance(row, dict): return False if str(row.get("pdno", stock_code) or stock_code) != stock_code: return False if str(row.get("sll_buy_dvsn_cd", "")) != "01": return False try: oq = int(float(row.get("ord_qty", 0) or 0)) cq = int(float(row.get("tot_ccld_qty", 0) or 0)) rj = int(float(row.get("rjct_qty", 0) or 0)) except (TypeError, ValueError): return False if oq <= 0: return False cancelled = str(row.get("cncl_yn", "") or "").upper() == "Y" if cq == 0 and (rj > 0 or cancelled): return False return True def _find_accepted_sell_row(rows, stock_code: str, qty: int | None, since) -> dict | None: """[H-2/M-4] 주문 수량·시각 범위까지 맞는 유효 매도 행. since=None 이면 시각 무관.""" _since_hms = None if since is not None: _w = float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC) _since_hms = (since - timedelta(seconds=_w)).strftime("%H%M%S") for r in rows or []: if not _row_is_live_or_filled_sell(r, stock_code): continue if qty is not None: try: if int(float(r.get("ord_qty", 0) or 0)) != int(qty): continue except (TypeError, ValueError): continue if _since_hms is not None: t = str(r.get("ord_tmd", "") or "") if t and t < _since_hms: continue odno = _norm_odno(r.get("odno", "")) if odno and (_today_key(), odno) in _finalized_sell_odnos: continue return r return None async def _lookup_todays_orders_impl(stock_code: str) -> list | None: try: return await asyncio.wait_for( korea_investment_api.get_todays_orders(stock_code), timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC), ) except asyncio.TimeoutError: logger.warning(f"[DayExit] 당일 주문내역 조회 시간초과 ({stock_code}) — 접수 미확인") except Exception as e: logger.warning(f"[DayExit] 당일 주문내역 조회 실패 ({stock_code}): {e} — 접수 미확인") return None async def _fetch_non_dt_active_qty_impl(stock_code: str) -> int | None: """[H-2 r5] 같은 종목의 비-DT(스윙·유입·legacy NULL) active ActiveTrade 수량 합. 실패면 None. 결과 불명 매도의 재매도 상한을 정하려면 계좌 보유분 중 **DT 몫**을 알아야 하는데, DB 없이 얻을 수 있는 소스가 없다: Portfolio 캐시(portfolio_cache_bridge)는 stock_code UNIQUE 로 스윙+DT 가 합산된 한 행이고(§3.5), 스윙 보유 인메모리 캐시는 존재하지 않는다 (swing/position_manager 는 전부 DB 세션 함수). close_stale_positions 와 같은 소스·같은 필터(not_dt_strategy_clause)를 쓴다. 결과 불명 이후에만 호출되는 드문 경로다. """ try: from app.core.database import AsyncSessionLocal from app.models.models import ActiveTrade from app.services.db_worker import not_dt_strategy_clause from sqlalchemy import select as _select # DB-READ-EXCEPTION: §3.2 ③ 과 같은 읽기 전용 SELECT 1건, commit/flush/add 없음. # 쓰기는 db_worker 경유. 결과 불명 매도의 재매도 상한 확인에서만 호출된다. async with AsyncSessionLocal() as _db: _rows = (await _db.execute( _select(ActiveTrade).where( ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", not_dt_strategy_clause(ActiveTrade.strategy_name), ) )).scalars().all() return sum(int(r.quantity or 0) for r in _rows) except Exception as e: logger.warning(f"[DayExit] 비-DT 보유 수량 조회 실패({stock_code}): {e}") return None async def _resolve_unknown_exit_v2_impl( stock_code: str, qty: int, since, total_qty: int, ) -> tuple[str, dict | None, int]: """[H-2 r5] 결과 불명 매도 판정 → (status, 접수 응답 dict|None, DT 매도가능수량). status: "accepted" — 계좌의 DT 몫이 주문 수량만큼 줄었다(접수·대기·체결 모두 주문가능수량에서 빠진다). 당일 주문내역에 맞는 행이 있으면 그 ODNO 로, 없으면 ODNO 없이 반영. "not_accepted" — DT 몫이 줄지 않았다 → 주문이 접수되지 않음. 재매도 상한 = DT 매도가능수량. "lookup_failed" — 잔고 또는 비-DT 수량 조회 실패 → 판단 불가(재매도 금지, 보류 유지). **판정의 근거는 잔고**다. `get_todays_orders` 는 조회 실패에도 `[]` 를 돌려줘 "실패"와 "없음"을 응답 형태로 구분할 수 없다(korea_investment_api 쪽 계약) → 주문내역은 ODNO 를 얻는 보조 소스로만 쓰고, 행이 있어도 잔고 감소가 없으면 접수로 보지 않는다 (같은 수량·시각의 스윙 매도를 DT 접수로 오인하지 않게). DT 매도가능수량 = max(0, min(주문가능, 보유) − 비-DT active 수량) """ h = await _fetch_kis_holdings_v2_impl() nd = await _fetch_non_dt_active_qty_impl(stock_code) if h is not None else None if h is None or nd is None: return "lookup_failed", None, 0 _hh = h.get(stock_code) or {} hldg = int(_hh.get("hldg", 0) or 0) sellable = int(_hh.get("sellable", 0) or 0) dt_sellable = max(0, min(sellable, hldg) - int(nd)) if dt_sellable <= max(0, int(total_qty) - int(qty)): rows = await _lookup_todays_orders_impl(stock_code) row = _find_accepted_sell_row(rows, stock_code, qty, since) odno = str((row or {}).get("odno", "") or "") logger.warning( f"[DayExit] {stock_code} 결과 불명 매도 → 접수로 판정: DT 매도가능 {dt_sellable}주 " f"(보유 {hldg}·주문가능 {sellable} − 비DT {nd}) ≤ 주문 전 {total_qty} − {qty}" f" / 주문내역 ODNO={odno or '미확인'}" ) return "accepted", {"rt_cd": "0", "msg1": "resolved_by_holdings", "output": {"ODNO": odno}}, dt_sellable return "not_accepted", None, dt_sellable def _note_unknown_lookup_failure(stock_code: str) -> None: """[M-3 r5] 불명 확인 조회 실패 — error(레이트리밋) + N회 연속이면 텔레그램 수동 청산 경보.""" key = (_today_key(), stock_code) n = _unknown_lookup_failures.get(key, 0) + 1 _unknown_lookup_failures[key] = n _error_throttled( f"dt_exit:unknown_lookup_failed:{stock_code}", f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 잔고/비DT 수량 조회 실패({n}회 연속) — " f"재매도 보류 중(손절 미집행). 수동 확인 필요", ) _limit = int(settings.DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT) if n >= _limit and key not in _unknown_lookup_alerted: _unknown_lookup_alerted.add(key) try: from app.services.telegram_notifier import telegram_notifier _t = asyncio.get_running_loop().create_task(asyncio.wait_for( telegram_notifier.notify_system_warning( "DT_EXIT_UNKNOWN", f"{stock_code} 데이트레이딩 매도 결과 불명 + 확인 조회 {n}회 연속 실패 — " f"손절이 보류된 상태입니다. 계좌를 확인하고 수동 청산하세요.", ), timeout=10.0, )) _alert_tasks.add(_t) _t.add_done_callback(_alert_tasks.discard) except Exception as e: logger.error(f"[DayExit] 수동 청산 경보 발송 실패({stock_code}): {e}") async def _place_and_finalize_exit_impl( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, quantity: int, total_qty: int, is_full_exit: bool, is_stale: bool, ) -> dict | None: """[H4] 매도 주문 + 체결 이후 상태 반영 (취소로부터 shield 되는 구간). 주문 결과를 `_last_exit_order_outcome[stock_code]` 에 남긴다: accepted(rt_cd=="0" 또는 불명 후 당일 주문내역에서 접수 확인) / rejected(전송 전 실패·KIS 업무 거부) / unknown(전송 후 응답 유실·DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC 초과 — 접수 미확인). 불명이면 잔고(주문가능·보유 − 비-DT active)로 접수 여부를 판정하고(_resolve_unknown_exit_v2_impl), 판정이 안 되면 DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC 동안 같은 종목 재매도를 보류한 뒤 재시도 직전에 다시 판정한다. 판정 불가(조회 실패)가 이어지면 손절이 보류된 채로 남는다 → DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT 회 연속이면 텔레그램 수동 청산 경보. ⚠️ [L-4] **장 마감 직전(대략 15:29:40 이후) 불명**이면 보류·재확인 주기가 15:30 폐장을 넘긴다. 그 경우 이 포지션은 **오버나이트로 남고**, 다음 영업일 자정 reset 이후 `close_stale_positions()`(ActiveTrade 기준 전일 포지션 청산 — 실보유·타전략 차감 확인 포함)가 장중 첫 사이클에 처리한다. 인메모리 불명 상태(_unresolved_exit_unknown)는 날짜 키라서 다음 날 판정에 새지 않는다(_prune_exit_state_for_today). """ from app.services.trade_executor import safe_place_order _prune_exit_state_for_today() _ukey = (_today_key(), stock_code) # [H-2] 직전 불명 주문이 미해결이면 새 주문 전에 다시 확인한다(잔고 기준). _prev = _unresolved_exit_unknown.get(_ukey) if _prev is not None: _st, _resp, _dt_sellable = await _resolve_unknown_exit_v2_impl( stock_code, int(_prev["qty"]), _prev["since"], int(_prev.get("total_qty", total_qty)), ) if _st == "lookup_failed": # 판단 불가 — 재매도하지 않고 보류를 이어간다(guarded finally 가 HOLD 뒤 해제) _last_exit_order_outcome[stock_code] = "unknown" _note_unknown_lookup_failure(stock_code) return None _unknown_lookup_failures.pop(_ukey, None) if _st == "accepted": _unresolved_exit_unknown.pop(_ukey, None) _last_exit_order_outcome[stock_code] = "accepted" _pq = min(int(_prev["qty"]), int(total_qty)) return await _finalize_after_accept_impl( stock_code, pos, price, reason, sell_pct if _pq < total_qty else 1.0, _resp, _pq, total_qty, _pq >= total_qty, is_stale, ) # not_accepted — 재매도 수량을 DT 매도가능수량으로 상한(스윙 물량 보호) if _dt_sellable <= 0: _unresolved_exit_unknown.pop(_ukey, None) _last_exit_order_outcome[stock_code] = "rejected" _error_throttled( f"dt_exit:unknown_no_sellable:{stock_code}", f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + DT 매도가능 0 — 재매도하지 않는다" f"(실보유·ActiveTrade 수동 확인)", ) return None if _dt_sellable < quantity: logger.warning( f"[DayExit] {stock_code} 직전 주문 결과 불명 — DT 매도가능 {_dt_sellable}주로 " f"매도 수량 축소({quantity}→{_dt_sellable})" ) quantity = _dt_sellable is_full_exit = quantity >= total_qty _unresolved_exit_unknown.pop(_ukey, None) _last_exit_order_outcome[stock_code] = "unknown" _since = _kst_now() _timeout = float(settings.DAY_TRADE_EXIT_ORDER_TIMEOUT_SEC) order_resp = None try: order_resp = await asyncio.wait_for( safe_place_order( stock_code=stock_code, order_type="SELL", quantity=quantity, price=None, # 시장가 ), timeout=_timeout, ) except asyncio.TimeoutError: logger.error(f"[DayExit] 매도 주문 응답 {_timeout:.0f}초 초과 ({stock_code}) — 결과 불명") outcome = _classify_order_response(order_resp) _r = order_resp if isinstance(order_resp, dict) else {} _last_exit_order_detail[stock_code] = { "outcome": outcome, "msg_cd": _r.get("msg_cd"), "msg1": _r.get("msg1"), } if outcome == "unknown": logger.error( f"[DayExit] 매도 주문 결과 불명 ({stock_code}): rt_cd={_r.get('rt_cd')} " f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — 당일 주문내역 확인" ) _st, resolved, _ = await _resolve_unknown_exit_v2_impl( stock_code, quantity, _since, total_qty, ) if _st != "accepted": # 아직 반영 전일 수 있다(주문가능수량 갱신 지연) → 기록만 하고 보류. 재시도 직전에 다시 확인. _unresolved_exit_unknown[_ukey] = { "qty": quantity, "since": _since, "total_qty": total_qty, } if _st == "lookup_failed": _note_unknown_lookup_failure(stock_code) return None order_resp = resolved outcome = "accepted" if outcome == "rejected": _last_exit_order_outcome[stock_code] = "rejected" logger.error( f"[DayExit] 매도 주문 실패 ({stock_code}): msg_cd={_r.get('msg_cd')} " f"{_r.get('msg1', 'unknown')}" ) return None _last_exit_order_outcome[stock_code] = "accepted" return await _finalize_after_accept_impl( stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit, is_stale, ) async def _finalize_after_accept_impl( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool, is_stale: bool, ) -> dict | None: """접수 확인된 매도의 상태 반영. [L-2] 반영 중 예외면 인메모리 최소 반영 + 불명 처리.""" _odno = _norm_odno(((order_resp or {}).get("output") or {}).get("ODNO", "")) if _odno: _finalized_sell_odnos.add((_today_key(), _odno)) # [2026-09-23] 여기서부터는 이미 체결된 주문의 상태 반영이다. 스케줄러의 단계 # 타임아웃(_guard)이 이 지점을 취소하면 "브로커는 팔았는데 인메모리는 그대로" 상태가 # 되어 다음 사이클이 같은 물량을 다시 판다 → shield 로 취소로부터 보호한다. try: if is_stale: return await asyncio.shield(_finalize_exit_stale( stock_code, pos, price, reason, order_resp, quantity, total_qty, )) return await asyncio.shield(_finalize_exit( stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit )) except Exception as e: logger.error( f"[DayExit] 🚨 {stock_code} 매도 접수 후 상태 반영 실패: {e} — 인메모리 최소 반영, " f"재매도 보류(결과 불명 처리)", exc_info=True, ) async with day_trading_state.trade_lock: _cur = day_trading_state.positions.get(stock_code) if _cur is not None and int(_cur.get("quantity", 0) or 0) == int(total_qty): if is_full_exit: day_trading_state.remove_position(stock_code) else: _cur["quantity"] = int(total_qty) - int(quantity) _cur["target_price"] = float("inf") _cur["partial_sold"] = True if is_full_exit: day_trading_state.closed_today.add(stock_code) _last_exit_order_outcome[stock_code] = "unknown" return None async def _finalize_exit( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool, ) -> dict | None: """체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간. 당일 포지션 전용 — 실현손익을 당일 daily_pnl / loss_streak_today 에 반영한다. (전일 포지션은 _finalize_exit_stale 사용 — M1) """ return await _finalize_exit_impl( stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit, count_daily_pnl=True, ) async def _finalize_exit_stale( stock_code: str, pos: dict, price: float, reason: str, order_resp: dict, quantity: int, total_qty: int, ) -> dict | None: """[M1] 전일(stale) 포지션 체결 이후 상태 반영. 당일 손익 집계(daily_pnl·loss_streak_today)에는 가산하지 않고, 별도 누적값(_stale_realized_pnl)과 로그로만 추적한다. DB TradeHistory 기록은 동일하게 수행되므로 실제 손익 추적은 유지된다. """ return await _finalize_exit_impl( stock_code, pos, price, reason, 1.0, order_resp, quantity, total_qty, True, count_daily_pnl=False, ) async def _finalize_exit_impl( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool, count_daily_pnl: bool = True, ) -> dict | None: """체결 이후 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변). count_daily_pnl=False 이면 당일 손익/연속손실 집계를 건너뛰고 전일 손익 누적값에만 더한다(전일 포지션 강제청산 경로). """ global _stale_realized_pnl entry_price = pos.get("entry_price", 0) stock_name = pos.get("stock_name", stock_code) order_id = order_resp.get("output", {}).get("ODNO", "") # [2026-10-01] 순손익(수수료·거래세 차감)으로 기록·집계한다. # 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)·당일 연패·loss_tracker 가 모두 이 값을 본다 # → 비용만큼 한도가 **약간 더 일찍** 발동한다(보수적 방향, 의도). 손익분기(gross≈0) # 청산도 순손익으로는 손실이다. _pnl = dt_net_pnl(entry_price, price, quantity, pos) gross_profit_loss = _pnl["gross_profit_loss"] trade_cost = _pnl["total_cost"] profit_loss = _pnl["net_profit_loss"] profit_loss_rate = _pnl["net_profit_loss_rate"] if entry_price > 0 else 0.0 # 인메모리 상태 업데이트 (DB 직접 접근 금지) async with day_trading_state.trade_lock: if is_full_exit: day_trading_state.remove_position(stock_code) if not count_daily_pnl: # [M10] DB에서만 발견된 전일 포지션은 인메모리에 없어 remove_position()의 # closed_today 추가가 일어나지 않는다 → 강제청산한 종목이 같은 날 # scan_and_enter 후보로 되살아난다. 명시적으로 재진입 금지 집합에 넣는다. day_trading_state.closed_today.add(stock_code) else: # 분할 매도: 잔량 유지, target_price 제거(트레일링만 적용) remaining = total_qty - quantity day_trading_state.positions[stock_code]["quantity"] = remaining day_trading_state.positions[stock_code]["target_price"] = float("inf") day_trading_state.positions[stock_code]["partial_sold"] = True if count_daily_pnl: day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit) else: _stale_realized_pnl += profit_loss logger.warning( f"[DayExit] 전일 포지션 실현손익은 당일 집계 제외: {stock_code} " f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)" ) # [M-2 2026-10-02] 인메모리 반영이 끝났으므로 청산 진행 중 표식을 **지금** 해제한다. # 예전에는 아래 꼬리 단계(Redis 저장·loss_tracker·포트폴리오 캐시) 뒤에 해제돼, 그중 # 하나가 멈추면 표식이 영구 잔류 → EOD 강제청산이 잔량을 계속 건너뛰었다. _release_exit_in_progress(stock_code) # DB 기록 (큐 경유) — 원장·ActiveTrade 를 꼬리 단계보다 먼저 남긴다 await _bounded_finalize_step(enqueue_trade_history({ "stock_code": stock_code, "stock_name": stock_name, "order_type": "SELL", "order_price": price, "order_quantity": quantity, "executed_price": price, "executed_quantity": quantity, "total_amount": price * quantity, "status": "COMPLETED", "order_id": order_id, "exit_reason": reason, "commission": trade_cost, "net_profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "strategy_type": "daytrading", }), stock_code, "TradeHistory 큐잉") # [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정. try: _schedule_sell_fill_reconcile( stock_code=stock_code, pos=dict(pos), order_id=order_id, signal_price=price, quantity=quantity, entry_price=entry_price, recorded_net=profit_loss, count_daily_pnl=count_daily_pnl, ) except Exception as e: logger.warning(f"[DayExit] {stock_code} 체결가 확인 예약 실패: {e}") if is_full_exit: await _bounded_finalize_step( enqueue_close_active_trade(stock_code, strategy_name="day_trade"), stock_code, "ActiveTrade close 큐잉", ) else: # 분할 매도 후 ActiveTrade 잔량 갱신 — 누락 시 재기동 복원에서 원래 수량이 되살아나 # 다음 청산이 보유량보다 많이 매도한다(2026-09-23 LG씨엔에스 4주 보유 중 7주 매도). from app.services.db_worker import enqueue_active_trade_upsert # [M1 2026-10-02] 분할매도 상태 영속화 — remaining_pct(<1.0)·highest_since_entry 를 # 함께 남긴다(둘 다 기존 ORM 컬럼 → db_worker payload 계약 무변경, 선택 키). # restore_from_db 가 remaining_pct<1.0 을 보고 목표가 분할 재발동을 막고, # 트레일링 고점을 이어받는다. Numeric(5,2) 반올림으로 1.00 이 되지 않게 0.99 상한. _remaining = total_qty - quantity _remaining_pct = min(0.99, round(_remaining / total_qty, 2)) if total_qty > 0 else 0.99 _highest = float(pos.get("highest_price") or 0) or None _payload = { "stock_code": stock_code, "strategy_name": "day_trade", "quantity": _remaining, "remaining_pct": _remaining_pct, } if _highest: _payload["highest_since_entry"] = _highest await _bounded_finalize_step( enqueue_active_trade_upsert(_payload), stock_code, "ActiveTrade 잔량 큐잉", ) # [F9 2026-09-28] 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화. # 값이 바뀐 직후에 저장한다 — 재기동해도 일일 -3% 한도·3연패 쿨다운·당일 # 재진입 차단이 풀리지 않게 한다(락 밖에서 호출: Redis I/O 로 락을 잡지 않는다). # [M-2] Redis 무응답이면 상한에서 끊는다(core/redis socket_timeout 은 인프라 소유). await _bounded_finalize_step(day_trading_state.save_daily_state(), stock_code, "일일 안전장치 저장") # [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적 # [2026-10-01] 시간손절은 **중립**(손실로도 이익으로도 세지 않음 → 기록 자체를 생략). # 기회비용 청산까지 3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지에 넣으면 그 밴이 # 스윙 진입(swing/entry_service)까지 막는다. 하드/폴백 손절·일일한도·EOD·트레일링 등 # 나머지 청산은 기존대로 기록한다. (당일 연패 쿨다운 loss_streak_today 는 위 update_pnl 이 # 그대로 센다 — DT 전용·당일 한정 스로틀이라 스윙에 번지지 않는다.) if is_full_exit: if is_time_stop_exit_reason(reason): logger.info( f"[DayExit] 시간손절은 연속손실 집계 제외(중립): {stock_code} " f"순손익={profit_loss:+.0f}원 — {settings.CONSECUTIVE_LOSS_BAN_COUNT}연패/" f"{settings.CONSECUTIVE_LOSS_BAN_DAYS}일 밴 카운트 변화 없음" ) else: is_loss = profit_loss < 0 async def _record_loss(): # record_trade_result() 후 반드시 save_to_db() (§5.1) await loss_tracker.record_trade_result_safe(stock_code, is_loss) await loss_tracker.save_to_db(stock_code=stock_code) await _bounded_finalize_step(_record_loss(), stock_code, "loss_tracker 기록") # 포트폴리오 화면에서 즉시 제거/차감 from app.services.portfolio_cache_bridge import reflect_sell await _bounded_finalize_step( reflect_sell(stock_code, quantity, price, remaining_qty=max(0, int(total_qty) - int(quantity))), stock_code, "포트폴리오 캐시 반영", ) sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량" logger.info( f"[DayExit] ✅ 청산({sell_label}): {stock_code} {stock_name} " f"수량={quantity}/{total_qty} 순수익률={profit_loss_rate:.2f}% " f"(총손익={gross_profit_loss:+.0f} 비용={trade_cost:.0f} 순손익={profit_loss:+.0f}원) 이유={reason}" ) return { "stock_code": stock_code, "stock_name": stock_name, "price": price, "quantity": quantity, "profit_loss": profit_loss, # 순손익(비용 차감) "profit_loss_rate": profit_loss_rate, # 순수익률(진입 원금 대비 %) "gross_profit_loss": gross_profit_loss, "trade_cost": trade_cost, "reason": reason, "sell_pct": sell_pct, "order_id": order_id, } async def _bounded_finalize_step(coro, stock_code: str, label: str) -> None: """[M-2] 체결 후 꼬리 단계 1개를 상한(DAY_TRADE_EXIT_FINALIZE_STEP_TIMEOUT_SEC)으로 실행. 한 단계가 멈추거나 실패해도 나머지 단계와 청산 결과 반환은 계속된다(error 로그). """ _t = float(settings.DAY_TRADE_EXIT_FINALIZE_STEP_TIMEOUT_SEC) try: await asyncio.wait_for(coro, timeout=_t) except asyncio.TimeoutError: logger.error(f"[DayExit] 🚨 {stock_code} 청산 후 '{label}' {_t:.0f}초 초과 — 건너뜀(수동 확인)") except Exception as e: logger.error(f"[DayExit] 🚨 {stock_code} 청산 후 '{label}' 실패: {e}", exc_info=True) def _schedule_sell_fill_reconcile( stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int, entry_price: float, recorded_net: float, count_daily_pnl: bool, ): """매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단). 확인되면: - TradeHistory(order_id) 의 체결가·비용·순손익·수익률·슬리피지(%)를 db_worker 로 갱신 - 기록했던 순손익과의 차이를 당일 daily_pnl 에 보정(전일 stale 청산은 _stale_realized_pnl — §7 당일 미반영 규칙 유지). 당일 연패·loss_tracker 판정은 청산 시점 값 그대로 둔다. 확인 실패면 신호가 기록이 그대로 남는다(fill_reconciler 가 warning). """ from app.core.timeutil import now_kst as _now_kst _order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회 async def _on_fill(filled_qty: int, avg_price: float) -> None: global _stale_realized_pnl if filled_qty < quantity: logger.warning( f"[DayExit] 매도 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 " f"order_id={order_id} — 인메모리 수량은 주문 수량 기준으로 반영돼 있다(수동 확인 필요)" ) new = dt_net_pnl(entry_price, avg_price, quantity, pos) delta = new["net_profit_loss"] - recorded_net slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0 from decimal import Decimal from app.services.db_worker import enqueue_update_trade_history_by_order_id await enqueue_update_trade_history_by_order_id(order_id, { # 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다 "order_date": _order_date, "order_type": "SELL", "strategy_type": "daytrading", "executed_price": Decimal(str(round(avg_price, 2))), "total_amount": Decimal(str(round(avg_price * quantity, 2))), "commission": Decimal(str(new["total_cost"])), "net_profit_loss": Decimal(str(round(new["net_profit_loss"], 2))), "profit_loss_rate": Decimal(str(round(new["net_profit_loss_rate"], 4))) if entry_price > 0 else None, "slippage": Decimal(str(round(slippage_pct, 4))), }) if delta: if count_daily_pnl: async with day_trading_state.trade_lock: day_trading_state.adjust_realized_pnl(delta) await day_trading_state.save_daily_state() else: _stale_realized_pnl += delta logger.info( f"[DayExit] 매도 체결가 확인: {stock_code} 신호가={signal_price:,.0f} " f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%) " f"순손익 {recorded_net:+.0f}→{new['net_profit_loss']:+.0f}원 " f"({'당일' if count_daily_pnl else '전일'} 손익 보정 {delta:+.0f}원)" ) return fill_reconciler.schedule(stock_code, order_id, "SELL", _on_fill) async def close_stale_positions() -> list[dict]: """진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산. EOD(15:20) 강제청산이 실패하거나 스케줄러가 그 구간을 놓친 경우의 안전장치. 장중에만 동작한다(시장가 주문 필요). 자정 reset 이후 전일 포지션은 DB 에만 남으므로 이 경로가 오버나이트 보유를 끝내는 유일한 장치다. 재시도 규칙 [H-A 2026-10-02]: - 주문이 접수(rt_cd=="0")되면 같은 날·같은 프로세스에서 다시 주문하지 않는다. - 명시적 거부(EGW00201 등)는 DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC 간격으로 최대 DAY_TRADE_STALE_SELL_MAX_RETRIES 회까지 재시도한다. - 결과 불명(응답 없음·예외·시간초과)이면 재시도 전에 당일 주문내역에서 이 종목 매도 접수 여부를 확인하고, 접수돼 있으면 재주문하지 않는다. - 이중 매도는 매 시도마다 다시 읽는 KIS 주문가능수량(ord_psbl_qty)으로도 막힌다. - 매도 수량 = min(행 수량, 주문가능수량, 보유수량 − 타 전략 보유분). - 주문 없이 ActiveTrade 만 닫는 정리는 **KIS 보유수량(hldg_qty)=0** 일 때만 한다. Returns: list[dict]: 청산 실행 결과 목록 """ if not is_market_open(): return [] today = _kst_now().date() results: list[dict] = [] # [2026-09-23] 인메모리 positions 만 순회하면 자정 reset_if_new_day() 이후에는 # 대상이 사라져 안전장치가 무력해진다 → DB(ActiveTrade)에서도 전일 포지션을 찾는다. stale: dict[str, dict] = {} for stock_code in list(day_trading_state.positions.keys()): pos = day_trading_state.get_position(stock_code) entry_time = (pos or {}).get("entry_time") entry_date = entry_time.date() if hasattr(entry_time, "date") else None if pos and entry_date is not None and entry_date < today: stale[stock_code] = pos # [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유] # SELECT 만 수행하며 commit()/flush()/add() 호출이 없다. 세션 수명은 아래 # async with 블록으로 최소화하고, 실패는 warning 으로 운영에 노출한다. # 실제 청산 기록은 아래 _execute_exit_stale → db_task_queue 경유로만 이뤄진다. # [H3 2026-10-02] 같은 세션에서 비-DT(스윙·유입 등) active 행 수량도 읽는다(§3.5 차감용). # 조회 실패면 None 으로 남겨 **매도하지 않는다**(과다매도 = 스윙 물량 매도 방지). other_strategy_qty: dict[str, int] | None = None try: from app.core.database import AsyncSessionLocal from app.models.models import ActiveTrade from app.services.db_worker import not_dt_strategy_clause from sqlalchemy import select as _select async with AsyncSessionLocal() as _db: _rows = (await _db.execute( _select(ActiveTrade).where( ActiveTrade.strategy_name == "day_trade", ActiveTrade.status == "active", ) )).scalars().all() for _row in _rows: _created = _row.created_at.date() if _row.created_at else None if _created is None or _created >= today or _row.stock_code in stale: continue _entry = float(_row.entry_price or 0) stale[_row.stock_code] = { "stock_name": _row.stock_name or _row.stock_code, "strategy": "day_trade", "entry_price": _entry, "quantity": int(_row.quantity or 0), "highest_price": float(_row.highest_since_entry or _entry), "entry_time": _row.created_at, "reason": "", "order_id": _row.order_id or "", } other_strategy_qty = {} if stale: _other_rows = (await _db.execute( _select(ActiveTrade).where( ActiveTrade.stock_code.in_(list(stale.keys())), ActiveTrade.status == "active", not_dt_strategy_clause(ActiveTrade.strategy_name), ) )).scalars().all() for _o in _other_rows: other_strategy_qty[_o.stock_code] = ( other_strategy_qty.get(_o.stock_code, 0) + int(_o.quantity or 0) ) except Exception as _db_err: logger.warning( f"[DayExit] 전일 포지션 DB 조회 실패(인메모리만 처리, 타 전략 보유분 미확인 → " f"매도 보류): {_db_err}" ) other_strategy_qty = None stale = { c: p for c, p in stale.items() if int((p or {}).get("quantity", 0) or 0) > 0 } if not stale: return results # [H3] KIS 실보유 조회 — 실패하면 None → 매도하지 않는다(warning). kis_holdings = await _fetch_kis_holdings_v2_impl() for stock_code, pos in stale.items(): entry_time = pos.get("entry_time") entry_date = entry_time.date() if hasattr(entry_time, "date") else None _attempt_key = (today.isoformat(), stock_code) if day_trading_state.is_exit_in_progress(stock_code): # [H-B] continue _already_accepted = _attempt_key in _stale_sell_attempted row_qty = int(pos.get("quantity", 0) or 0) _mem_pos = day_trading_state.positions.get(stock_code) _mem_is_other = _mem_pos is not None and _mem_pos is not pos if _mem_is_other: # 같은 종목에 당일 DT 포지션과 전일 DB 행이 공존 — close_active_trade 가 # '가장 최근 day_trade 행'을 닫으므로 당일 행을 잘못 닫을 수 있다. 자동 처리 보류. _error_throttled( "dt_exit:stale_coexist", f"[DayExit] 🚨 {stock_code}: 당일 DT 포지션과 전일 미청산 DB 행이 공존 — " f"자동 청산·정리를 보류한다(수동 확인 필요)", ) continue if kis_holdings is None: logger.warning( f"[DayExit] ⚠️ 전일 포지션 {stock_code} 매도 보류 — KIS 실보유 조회 실패 " f"(실보유 미확인 상태로 팔면 스윙 물량을 팔 수 있다). 다음 사이클 재시도" ) continue if other_strategy_qty is None: logger.warning( f"[DayExit] ⚠️ 전일 포지션 {stock_code} 매도 보류 — 타 전략 보유분 조회 실패" f"(§3.5 차감 불가). 다음 사이클 재시도" ) continue _h = kis_holdings.get(stock_code) or {"hldg": 0, "sellable": 0} hldg_qty = int(_h.get("hldg", 0) or 0) sellable_qty = int(_h.get("sellable", 0) or 0) other_qty = int(other_strategy_qty.get(stock_code, 0) or 0) # [M-A] 타 전략 차감은 **보유수량** 기준으로 한다. 주문가능수량(ord_psbl_qty)에는 이미 # 대기 중인 스윙 매도분이 빠져 있어 거기서 또 빼면 이중 차감된다. 주문가능수량은 상한으로만. dt_available = max(0, min(sellable_qty, hldg_qty - other_qty)) sell_qty = min(row_qty, dt_available) if hldg_qty <= 0 and _attempt_key not in _stale_cleaned: # [L-1] 실보유 0 — 이미 팔렸다(접수된 매도의 체결 포함). 주문 없이 ActiveTrade 만 정리. sell_qty = 0 elif _already_accepted: # [H3/H-A] 이번 프로세스에서 이미 매도 주문이 **접수된** 종목은 재주문 금지. # 예전에는 매도 성공 후 ActiveTrade close 가 큐에서 반영되기 전(또는 엉뚱한 행이 # 닫혀) 다음 사이클 DB 조회가 같은 행을 다시 stale 로 잡아 **반복 매도**했다. warn_throttled( logger, "dt_exit:stale_already_attempted", f"[DayExit] 전일 포지션 {stock_code} 은 이번 프로세스에서 매도 주문이 이미 접수됨 — " f"재주문하지 않는다(체결 후 보유 0 이 되면 ActiveTrade 를 정리)", ) continue elif hldg_qty <= 0: continue # 이미 정리함 if sell_qty <= 0: if hldg_qty <= 0: # 실보유 0 — 이미 팔렸다. 주문 없이 ActiveTrade(day_trade)만 정리 _stale_sell_attempted.add(_attempt_key) _stale_cleaned.add(_attempt_key) logger.warning( f"[DayExit] ⚠️ 전일 포지션 {stock_code} KIS 보유 0주 (행 {row_qty}주) " f"— 매도 없이 ActiveTrade(day_trade)만 닫는다" ) async with day_trading_state.trade_lock: if day_trading_state.positions.get(stock_code) is pos: day_trading_state.remove_position(stock_code) day_trading_state.closed_today.add(stock_code) await day_trading_state.save_daily_state() await enqueue_close_active_trade(stock_code, strategy_name="day_trade") else: # 보유는 있는데 DT 가용이 0 — 대기 매도·유령 비-DT 행 등으로 판단 불가. # 행을 닫으면 실보유가 있는데 추적이 끊기므로 유지하고 사람에게 넘긴다. _error_throttled( f"dt_exit:stale_no_dt_available:{stock_code}", f"[DayExit] 🚨 전일 포지션 {stock_code}: KIS 보유 {hldg_qty}주(주문가능 " f"{sellable_qty}주) − 타전략 {other_qty}주 → DT 가용 0, 행 {row_qty}주 — " f"매도·정리 보류(ActiveTrade 유지). 수동 확인 필요", ) continue # [H-A] 실패 이력이 있으면 백오프·최대 횟수·결과 불명 확인 _fail = _stale_sell_failures.get(_attempt_key) if _fail is not None: _cnt, _last = _fail _max = int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES) _wait = float(settings.DAY_TRADE_STALE_SELL_RETRY_BACKOFF_SEC) if _cnt >= _max: # [M-3] 업무 거부가 한도에 닿아도 당일 포기하지 않는다 — 긴 간격으로 계속 시도 _wait = float(settings.DAY_TRADE_STALE_SELL_EXHAUSTED_RETRY_SEC) _error_throttled( f"dt_exit:stale_retry_exhausted:{stock_code}", f"[DayExit] 🚨 전일 포지션 {stock_code} 매도 업무 거부 {_cnt}회(한도 {_max}회) — " f"{_wait:.0f}초 간격으로 계속 재시도한다. 수동 청산 권장(오버나이트 보유 위험)", ) if time.monotonic() - _last < _wait: continue if _last_exit_order_outcome.get(stock_code) == "unknown": _lq = int(_stale_last_qty.get(_attempt_key) or 0) # [L-1 r5] 주문내역 행만으로는 스윙 매도와 구분이 안 된다 → 잔고 기준 DT 가용이 # 직전 주문 수량만큼 줄었을 때만 접수로 본다(줄지 않았으면 재매도 — 수량 상한은 위 # dt_available 이 이미 걸려 있다). _reduced = dt_available <= max(0, row_qty - _lq) _acc = _reduced and await _stale_sell_accepted_today_impl( stock_code, qty=_lq or None, since=_stale_last_since.get(_attempt_key), ) if _acc: _stale_sell_attempted.add(_attempt_key) logger.error( f"[DayExit] 🚨 전일 포지션 {stock_code}: 직전 주문 응답은 불명이었으나 " f"당일 매도 접수가 확인됨 — 재주문하지 않는다(체결 후 보유 0 이면 다음 " f"사이클이 ActiveTrade 를 정리)" ) continue sell_pos = pos if sell_qty < row_qty: logger.warning( f"[DayExit] ⚠️ 전일 포지션 {stock_code} 수량 불일치 — 행 {row_qty}주, " f"KIS 보유 {hldg_qty}주(주문가능 {sellable_qty}주) − 타전략 {other_qty}주 " f"= DT 가용 {dt_available}주 → {sell_qty}주만 매도" ) sell_pos = dict(pos) sell_pos["quantity"] = sell_qty price = 0.0 try: price_resp = await korea_investment_api.get_current_price(stock_code) price = float(price_resp.get("output", {}).get("stck_prpr", 0) or 0) if price_resp else 0.0 except Exception as e: logger.warning(f"[DayExit] 전일 포지션 현재가 조회 실패 ({stock_code}): {e}") if price <= 0: price = float(pos.get("entry_price", 0) or 0) if price <= 0: continue logger.warning( f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} " f"(진입일={entry_date}, 수량={sell_qty}/{row_qty})" ) # [M10] 청산 성공/실패와 무관하게 당일 재진입은 금지한다. 그 사이 # scan_and_enter 가 같은 종목을 다시 매수하면 전일 포지션이 되살아난다. async with day_trading_state.trade_lock: day_trading_state.closed_today.add(stock_code) # [F9] closed_today 변경분 영속화 — 재기동 후에도 당일 재진입을 막는다 await day_trading_state.save_daily_state() # [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다. _last_exit_order_outcome.pop(stock_code, None) _last_exit_order_detail.pop(stock_code, None) _stale_last_qty[_attempt_key] = sell_qty _stale_last_since[_attempt_key] = _kst_now() result = None try: result = await _execute_exit_stale( stock_code, sell_pos, price, reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)", ) except Exception as e: logger.error(f"[DayExit] 전일 포지션 {stock_code} 매도 예외: {e}", exc_info=True) _outcome = _last_exit_order_outcome.get(stock_code) if result or _outcome == "accepted": # [H-A] 시도 기록은 **접수 성공 시에만** 남긴다 _stale_sell_attempted.add(_attempt_key) _stale_sell_failures.pop(_attempt_key, None) if result: results.append(result) else: # [M-3] 일시 실패(레이트리밋·전송 전·전송 불명)는 횟수에 세지 않는다 — 업무 거부만 센다 _detail = _last_exit_order_detail.get(stock_code) _prev_cnt = _stale_sell_failures.get(_attempt_key, (0, 0.0))[0] _transient = _is_transient_order_failure(_detail) _cnt = _prev_cnt if _transient else _prev_cnt + 1 _stale_sell_failures[_attempt_key] = (_cnt, time.monotonic()) logger.error( f"[DayExit] 🚨 전일 포지션 {stock_code} 매도 실패 " f"({'일시 실패 — 미산정' if _transient else '업무 거부'}, 누적 {_cnt}/" f"{int(settings.DAY_TRADE_STALE_SELL_MAX_RETRIES)}회, 결과={_outcome or '미상'}, " f"msg_cd={(_detail or {}).get('msg_cd')}) — 백오프 뒤 재시도" ) await asyncio.sleep(0.3) # KIS 레이트 리밋 return results def _error_throttled(key: str, msg: str) -> None: """[Low] 같은 key 의 error 로그를 레이트리밋(30초 사이클마다 반복되는 경고 억제).""" from app.core.log_throttle import should_log ok, suppressed = should_log(key) if ok: logger.error(msg + (f" (+{suppressed}건 억제)" if suppressed else "")) async def _stale_sell_accepted_today_impl(stock_code: str, qty: int | None = None, since=None) -> bool: """[H-A/M-4] 당일 주문내역에 이 종목의 **유효한** 매도 접수가 있는지. 거부·취소(체결 0) 행은 접수로 보지 않고, 주문 수량(qty)·주문 시각(since − 허용오차 이후) 으로 범위를 좁힌다(같은 날 스윙 매도·거부 행을 DT 접수로 오인하던 결함). 조회 실패·없음 → False — 그 경우 재시도 주문 수량은 다시 읽은 주문가능수량(ord_psbl_qty)이 제한한다. """ rows = await _lookup_todays_orders_impl(stock_code) return _find_accepted_sell_row(rows, stock_code, qty, since) is not None async def _fetch_kis_holdings_v2_impl() -> dict[str, dict] | None: """[H3/M-A] KIS 계좌 실보유 {종목코드: {"hldg": 보유수량, "sellable": 주문가능수량}}. 조회 실패면 None (warning). 빈 목록은 "보유 0"(정상 응답)이다. `ord_psbl_qty` 가 없으면 주문가능수량 = 보유수량으로 본다. 주문가능수량에는 대기 중인 매도 주문분이 이미 빠져 있다. """ try: bal = await asyncio.wait_for( korea_investment_api.get_balance(), timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC), ) except asyncio.TimeoutError: logger.warning(f"[DayExit] KIS 잔고 조회 {settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC:.0f}초 초과 — 실보유 미확인") return None except Exception as e: logger.warning(f"[DayExit] KIS 잔고 조회 실패 — 실보유 미확인: {e}") return None if not isinstance(bal, dict): logger.warning(f"[DayExit] KIS 잔고 응답 형식 이상({type(bal).__name__}) — 실보유 미확인") return None if bal.get("rt_cd") not in (None, "0"): logger.warning(f"[DayExit] KIS 잔고 조회 오류 응답: {bal.get('msg1')} — 실보유 미확인") return None rows = bal.get("output1") if not isinstance(rows, list): logger.warning("[DayExit] KIS 잔고 응답에 output1 없음 — 실보유 미확인") return None out: dict[str, dict] = {} for h in rows: if not isinstance(h, dict): continue code = str(h.get("pdno", "") or "") if not code: continue try: hq = max(0, int(float(h.get("hldg_qty", 0) or 0))) sq = hq if h.get("ord_psbl_qty") not in (None, ""): sq = max(0, min(hq, int(float(h.get("ord_psbl_qty") or 0)))) except (TypeError, ValueError): logger.warning(f"[DayExit] KIS 잔고 수량 파싱 실패 ({code}) — 실보유 미확인") return None cur = out.setdefault(code, {"hldg": 0, "sellable": 0}) cur["hldg"] += hq cur["sellable"] += sq return out async def force_close_all(reason: str = "강제 청산") -> list[dict]: """모든 데이트레이딩 포지션 강제 청산. 호출 시점: - EOD 15:20 (CLAUDE.md 7.3) - 일일 손실 한도(-3%) 초과 - 수동 API 호출 현재가 조회 실패 시 진입가를 대체가로 사용. KIS API 레이트 리밋 방지를 위해 종목 간 0.3초 대기. Args: reason: 강제 청산 사유 (로그 및 DB 기록용) Returns: list[dict]: 청산 실행 결과 목록 """ results = [] failed_codes: list[str] = [] codes = list(day_trading_state.positions.keys()) for stock_code in codes: pos = day_trading_state.get_position(stock_code) if not pos: continue # [H-B] 진행 중 청산 주문이 있으면 건너뛴다. 진행 중 표식은 shield 작업의 finally 가 # 해제하므로(결과 불명이면 DAY_TRADE_EXIT_UNKNOWN_HOLD_SEC 뒤) 다음 EOD 사이클(30초)에 # 남은 포지션을 다시 잡는다 — 영구 skip 되지 않는다. if day_trading_state.is_exit_in_progress(stock_code): logger.warning(f"[DayExit] 강제 청산: {stock_code} 청산 주문 진행 중 — 이번 회차 건너뜀") continue try: # 현재가 조회 price_resp = await korea_investment_api.get_current_price(stock_code) price = float( price_resp.get("output", {}).get("stck_prpr", 0) or 0 ) if price_resp else 0.0 if price <= 0: price = float(pos.get("entry_price", 0)) result = await _execute_exit(stock_code, pos, price, reason) if result: results.append(result) await asyncio.sleep(0.3) # KIS API 레이트 리밋 except Exception as e: logger.error(f"[DayExit] 강제 청산 오류 ({stock_code}): {e}", exc_info=True) failed_codes.append(stock_code) # S4 fix: 실패 종목 1회 재시도 if failed_codes: logger.warning(f"[DayExit] 강제 청산 실패 {len(failed_codes)}건 — 2초 후 재시도: {failed_codes}") await asyncio.sleep(2.0) still_failed: list[str] = [] for stock_code in failed_codes: pos = day_trading_state.get_position(stock_code) if not pos or day_trading_state.is_exit_in_progress(stock_code): continue try: price_resp = await korea_investment_api.get_current_price(stock_code) price = float( price_resp.get("output", {}).get("stck_prpr", 0) or 0 ) if price_resp else 0.0 if price <= 0: price = float(pos.get("entry_price", 0)) result = await _execute_exit(stock_code, pos, price, reason) if result: results.append(result) await asyncio.sleep(0.3) except Exception as e: logger.critical(f"[DayExit] 강제 청산 재시도 실패 ({stock_code}): {e} — 포지션 미청산 위험!", exc_info=True) still_failed.append(stock_code) if still_failed: logger.critical(f"[DayExit] 최종 미청산 종목: {still_failed} — 수동 청산 필요!") logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})") return results