"""데이트레이딩 청산 서비스 당일 포지션의 청산 조건 체크 및 실행: - 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분(6봉) +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 from datetime import datetime import pytz 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, ) from app.services.daytrading.position_manager import day_trading_state from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade from app.services.loss_tracker import loss_tracker logger = logging.getLogger(__name__) # 한국 표준시(KST) 타임존 _KST = pytz.timezone("Asia/Seoul") def _kst_now() -> datetime: """현재 KST 시간을 timezone-naive datetime으로 반환. entry_time과 비교 시 timezone 불일치 방지를 위해 tzinfo 제거. Returns: datetime: 현재 KST 시간 (timezone-naive) """ return datetime.now(_KST).replace(tzinfo=None) 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: return exits # 일일 손실 한도 초과 시 전체 강제 청산 # [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 # 개별 포지션 청산 체크 codes_snapshot = list(day_trading_state.positions.keys()) for stock_code in codes_snapshot: try: exit_result = await _check_single_position(stock_code) if exit_result: exits.append(exit_result) except Exception as e: logger.error(f"[DayExit] 포지션 체크 오류 ({stock_code}): {e}", exc_info=True) return exits 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 entry_price = pos.get("entry_price", 0) # [FIX] 0 값이 저장된 경우 dict.get() 기본값이 적용되지 않으므로 `or`로 방어 # (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨) stop_loss = pos.get("stop_loss") or (entry_price * 0.94) target_price = pos.get("target_price") or (entry_price * 1.06) quantity = pos.get("quantity", 0) highest_price = pos.get("highest_price", entry_price) entry_time = pos.get("entry_time") # 5분봉 데이터 조회 try: raw = await korea_investment_api.get_minute_chart_full_day(stock_code) candles = parse_minute_candles(raw) candles_5m = aggregate_to_5min(candles) if len(candles_5m) < 5: return None current_price = float(candles_5m[-1].close) if candles_5m else 0.0 # highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완) candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price new_highest = max(current_price, candle_high, 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, ) if exit_signal: sell_pct = exit_signal.sell_pct return await _execute_exit( stock_code, pos, current_price, exit_signal.reason, sell_pct=sell_pct, ) except Exception as e: logger.debug(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e}") 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 등) """ total_qty = pos.get("quantity", 0) entry_price = pos.get("entry_price", 0) stock_name = pos.get("stock_name", stock_code) 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) # KIS API 매도 주문 (시장가) — 분산락+세마포어 보호 from app.services.trade_executor import safe_place_order order_resp = await safe_place_order( stock_code=stock_code, order_type="SELL", quantity=quantity, price=None, # 시장가 ) if not order_resp or order_resp.get("rt_cd") != "0": err = order_resp.get("msg1", "unknown") if order_resp else "no response" logger.error(f"[DayExit] 매도 주문 실패 ({stock_code}): {err}") return None order_id = order_resp.get("output", {}).get("ODNO", "") profit_loss = (price - entry_price) * quantity profit_loss_rate = ((price - entry_price) / entry_price * 100) 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) 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 day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit) # [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적 if is_full_exit: is_loss = profit_loss < 0 await loss_tracker.record_trade_result_safe(stock_code, is_loss) await loss_tracker.save_to_db(stock_code=stock_code) # DB 기록 (큐 경유) await 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, "net_profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "strategy_type": "daytrading", }) if is_full_exit: await enqueue_close_active_trade(stock_code, strategy_name="day_trade") else: # 분할 매도 후 ActiveTrade 잔량 갱신 — 누락 시 재기동 복원에서 원래 수량이 되살아나 # 다음 청산이 보유량보다 많이 매도한다(2026-09-23 LG씨엔에스 4주 보유 중 7주 매도). from app.services.db_worker import enqueue_active_trade_upsert await enqueue_active_trade_upsert({ "stock_code": stock_code, "strategy_name": "day_trade", "quantity": total_qty - quantity, }) # 포트폴리오 화면에서 즉시 제거/차감 from app.services.portfolio_cache_bridge import reflect_sell await reflect_sell(stock_code, quantity, price, remaining_qty=max(0, int(total_qty) - int(quantity))) 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:.1f}% 이유={reason}" ) return { "stock_code": stock_code, "stock_name": stock_name, "price": price, "quantity": quantity, "profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "reason": reason, "sell_pct": sell_pct, } async def close_stale_positions() -> list[dict]: """진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산. EOD(15:20) 강제청산이 실패하거나 스케줄러가 그 구간을 놓친 경우의 안전장치. 장중에만 동작하며(시장가 주문 필요), 청산 실패 시 다음 사이클에서 재시도된다. Returns: list[dict]: 청산 실행 결과 목록 """ if not is_market_open(): return [] today = _kst_now().date() results: list[dict] = [] for stock_code in list(day_trading_state.positions.keys()): pos = day_trading_state.get_position(stock_code) if not pos: continue entry_time = pos.get("entry_time") if entry_time is None: continue entry_date = entry_time.date() if hasattr(entry_time, "date") else None if entry_date is None or entry_date >= today: continue 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}, 수량={pos.get('quantity')})" ) result = await _execute_exit( stock_code, pos, price, reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)", sell_pct=1.0, ) if result: results.append(result) await asyncio.sleep(0.3) # KIS 레이트 리밋 return results 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 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: 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