""" kis_trader/execution/orphan_reconcile.py — 봇 고아 포지션 복구 (장마감 전·후 배치) ================================================================================ 실계좌 잔고(inquire-balance, 페이징)와 active_trades·orders 를 대조해 「봇이 매수했는데 active_trades 미기록」 고아만 복구한다. 이어서 GHOST_PURGE_ON_RECONCILE 시 「DB有·브로커0」 유령 행을 삭제한다. - Pre-EOD: 활성 전략 중 가장 이른 EOD 시각 − N분 (기본 7분) 1회 - Post-EOD: 15:36~ 장마감 후 1회 (기존) - 수동매수(orders BUY 없음) · MANUAL_HOLD_CODES · strategy=HOLDING 은 건드리지 않음. - 매매 알림은 보내지 않음(로그 + 선택적 요약 MM). """ from __future__ import annotations import datetime import json import logging from typing import Any, Dict, List, Optional, Set, Tuple from ..engine.strategy_eod import _STRATEGY_EOD_SPEC, parse_eod_hm, resolve_strategy_eod_params from ..utils.env import get_env_bool, get_env_from_db, get_env_int from ..utils.strategy_ids import canonical_strategy_id logger = logging.getLogger("kis_trader.orphan_reconcile") # Pre-EOD 시각 산출 — (전략 ON 플래그, strategy_eod spec 키) _PRE_EOD_STRATEGY_FLAGS: Tuple[Tuple[str, str, bool], ...] = ( ("STRATEGY_BREAKOUT_ENABLED", "BREAKOUT", False), ("STRATEGY_MOMENTUM_ENABLED", "MOMENTUM", False), ("STRATEGY_SHORT_ENABLED", "TAIL", True), ) def resolve_pre_eod_reconcile_hm() -> Tuple[int, int]: """ 활성 전략 EOD 시각 중 가장 이른 시각 − ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN 분. 활성 EOD 없으면 BREAKOUT 기본 15:15 기준. """ lead = max(1, get_env_int("ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN", 7)) candidates: List[int] = [] for flag, sid, def_on in _PRE_EOD_STRATEGY_FLAGS: if not get_env_bool(flag, def_on): continue spec = _STRATEGY_EOD_SPEC.get(sid) if spec is None: continue en_key, hm_key, def_en, def_hm, leg_key = spec params: Dict[str, Any] = { en_key: get_env_from_db(en_key, str(def_en)), hm_key: get_env_from_db(hm_key, def_hm), } if leg_key: params[leg_key] = get_env_from_db(leg_key, str(def_en)) enabled, eod_hm = resolve_strategy_eod_params(params, sid) if not enabled: continue hh, mm = parse_eod_hm(eod_hm, def_hm) candidates.append(hh * 60 + mm) if not candidates: base_min = 15 * 60 + 15 else: base_min = min(candidates) pre_min = max(9 * 60, base_min - lead) return pre_min // 60, pre_min % 60 def is_pre_eod_reconcile_window( now: Optional[datetime.datetime] = None, *, window_min: int = 3, ) -> bool: """Pre-EOD 고아복구 실행 윈도우 (기본 3분).""" if not get_env_bool("ORPHAN_RECONCILE_PRE_EOD_ENABLED", True): return False if not get_env_bool("ORPHAN_RECONCILE_ENABLED", True): return False t = now or datetime.datetime.now() pre_h, pre_m = resolve_pre_eod_reconcile_hm() cur = t.hour * 60 + t.minute start = pre_h * 60 + pre_m return start <= cur < start + max(1, window_min) def parse_manual_hold_codes() -> Set[str]: """MANUAL_HOLD_CODES env — 쉼표/세미콜론 구분 종목코드.""" manual_raw = str(get_env_from_db("MANUAL_HOLD_CODES", "") or "") return { c.strip() for c in manual_raw.replace(";", ",").split(",") if c.strip() } def get_bot_bought_codes(db) -> Set[str]: """orders 테이블에 BUY 기록이 있는 종목코드.""" codes: Set[str] = set() try: cur = db.conn.execute("SELECT DISTINCT code FROM orders WHERE side='BUY'") for row in (cur.fetchall() or []): c = str(row.get("code") or "").strip() if c: codes.add(c) except Exception as e: logger.warning("orders BUY 코드 조회 실패: %s", e) return codes def get_portfolio_origin_sets(db) -> Tuple[Set[str], Set[str]]: """(bot_bought_codes, manual_hold_codes) — backtest_web 보유·매도 탭과 동일.""" return get_bot_bought_codes(db), parse_manual_hold_codes() def _tracked_codes(db) -> Set[str]: """active_trades 에 한 건이라도 있는 종목코드 (전 전략).""" out: Set[str] = set() try: cur = db.conn.execute("SELECT DISTINCT code FROM active_trades") for row in (cur.fetchall() or []): c = str(row.get("code") or "").strip() if c: out.add(c) except Exception as e: logger.warning("active_trades 코드 조회 실패: %s", e) return out def _latest_buy_order(db, code: str) -> Optional[Dict[str, Any]]: """해당 종목 최신 BUY orders 행 (복구 메타·체결가 참고).""" try: cur = db.conn.execute( """ SELECT * FROM orders WHERE side='BUY' AND code=%s ORDER BY submitted_at DESC LIMIT 1 """, (code,), ) row = cur.fetchone() return dict(row) if row else None except Exception as e: logger.warning("orders 최신 BUY 조회 실패 %s: %s", code, e) return None def _parse_order_meta(row: Dict[str, Any]) -> Dict[str, Any]: """orders.raw_json 에서 stop/target 등 복구.""" meta: Dict[str, Any] = {} raw = row.get("raw_json") if not raw: return meta try: d = json.loads(raw) if isinstance(raw, str) else raw if isinstance(d, dict): meta = d except Exception: pass return meta def _num(v, default: float = 0.0) -> float: try: return float(str(v or 0).replace(",", "").strip()) except Exception: return default def _broker_qty(broker: Dict[str, Any], code: str) -> int: br = (broker or {}).get(code) or (broker or {}).get(str(code).strip()) if not br: return 0 try: return int((br or {}).get("qty") or 0) except (TypeError, ValueError): return 0 def purge_ghost_active_trades(order_mgr, broker: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ DB(active_trades) 有 · 브로커 0주 → 유령 행 삭제. - MANUAL_HOLD_CODES · strategy=HOLDING(장기) 은 보호(삭제 안 함). - 잔고 API 실패 시 삭제하지 않음(오판 방지). """ out: Dict[str, Any] = {"purged": [], "skipped_manual": [], "skipped_holding": [], "failed": []} if not get_env_bool("GHOST_PURGE_ON_RECONCILE", True): out["msg"] = "GHOST_PURGE_ON_RECONCILE=false" return out db = order_mgr.db if broker is None: broker = order_mgr.get_broker_holdings(force=True) if not getattr(order_mgr, "_holdings_last_fetch_ok", False): out["error"] = "실계좌 잔고 조회 실패" return out manual_hold = parse_manual_hold_codes() try: rows = db.conn.execute( "SELECT code, name, strategy, current_qty FROM active_trades" ).fetchall() or [] except Exception as e: out["error"] = str(e) logger.warning("유령잔고 조회 실패: %s", e) return out for row in rows: d = dict(row) code = str(d.get("code") or "").strip() if not code: continue name = str(d.get("name") or code) strategy = str(d.get("strategy") or "").strip() if code in manual_hold: out["skipped_manual"].append({"code": code, "name": name, "strategy": strategy}) continue # 장기 홀딩봇 포지션은 당일 EOD 대상이 아님 — 브로커 0이어도 수동 확인 전 보존 if strategy.upper() == "HOLDING": out["skipped_holding"].append({"code": code, "name": name}) continue if _broker_qty(broker, code) > 0: continue try: db.delete_active_trade(code=code, strategy=strategy or None) out["purged"].append({ "code": code, "name": name, "strategy": strategy, "qty": int(d.get("current_qty") or 0), }) logger.warning( "🧹 [유령잔고삭제] [%s] %s %s — 브로커 0주 → active_trades 삭제", strategy, name, code, ) except Exception as e: out["failed"].append({"code": code, "name": name, "error": str(e)}) logger.exception("유령잔고 삭제 실패 %s", code) out["purged_count"] = len(out["purged"]) out["failed_count"] = len(out["failed"]) if out["purged"]: try: order_mgr.invalidate_holdings_cache() except Exception: pass return out def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]: """ 장마감 후 1회 호출 — 봇 고아만 active_trades 에 upsert. 이어서 GHOST_PURGE_ON_RECONCILE 시 유령(DB有·브로커0) 삭제. Returns: reconciled, skipped_tracked, skipped_manual, skipped_no_order, failed, ghost_purged, ghost_purged_count """ result: Dict[str, Any] = { "reconciled": [], "skipped_tracked": [], "skipped_manual": [], "skipped_no_order": [], "failed": [], "ghost_purged": [], } if not get_env_bool("ORPHAN_RECONCILE_ENABLED", True): result["msg"] = "ORPHAN_RECONCILE_ENABLED=false" return result db = order_mgr.db broker = order_mgr.get_broker_holdings(force=True) if not getattr(order_mgr, "_holdings_last_fetch_ok", False): result["error"] = "실계좌 잔고 조회 실패" logger.warning("🧩 [고아복구] 잔고 API 실패 — 스킵") return result tracked = _tracked_codes(db) bot_bought, manual_hold = get_portfolio_origin_sets(db) for code, br in (broker or {}).items(): c = str(code).strip() qty = int((br or {}).get("qty") or 0) if qty <= 0: continue name = str((br or {}).get("name") or c) if c in manual_hold: result["skipped_manual"].append({"code": c, "name": name, "qty": qty}) continue if c in tracked: result["skipped_tracked"].append({"code": c, "name": name, "qty": qty}) continue if c not in bot_bought: result["skipped_no_order"].append({"code": c, "name": name, "qty": qty}) continue order_row = _latest_buy_order(db, c) if not order_row: result["skipped_no_order"].append({"code": c, "name": name, "qty": qty}) continue meta = _parse_order_meta(order_row) strategy = canonical_strategy_id( str(order_row.get("strategy_id") or meta.get("strategy_id") or "SCALP") ) filled_px = _num(order_row.get("filled_avg_price")) if filled_px <= 0: filled_px = _num(order_row.get("price") or meta.get("price_ref")) br_avg = _num((br or {}).get("avg_price")) avg_buy = br_avg if br_avg > 0 else filled_px cur_px = _num((br or {}).get("current_price")) or avg_buy buy_date = ( str(order_row.get("filled_at") or order_row.get("submitted_at") or "").strip() or None ) stop_px = _num(meta.get("stop_price")) target_px = _num(meta.get("target_price")) atr_entry = _num(meta.get("atr_entry")) trade_data = { "code": c, "name": str(order_row.get("name") or meta.get("name") or name), "strategy": strategy, "avg_buy_price": avg_buy, "current_price": cur_px, "stop_price": stop_px, "target_price": target_px, "max_price": max(avg_buy, cur_px), "atr_entry": atr_entry, "target_qty": qty, "current_qty": qty, "total_invested": avg_buy * qty, "status": "HOLDING", "buy_date": buy_date, "size_class": meta.get("size_class") or "", "entry_features": meta.get("entry_features") or {}, } try: ok = db.upsert_trade(trade_data) if ok: result["reconciled"].append({ "code": c, "name": trade_data["name"], "strategy": strategy, "qty": qty, "avg_buy": avg_buy, }) logger.info( "🧩 [고아복구] %s(%s) [%s] %d주 @ %.0f → active_trades", trade_data["name"], c, strategy, qty, avg_buy, ) else: result["failed"].append({"code": c, "name": name, "error": "upsert_trade 실패"}) except Exception as e: result["failed"].append({"code": c, "name": name, "error": str(e)}) logger.exception("고아복구 upsert 실패 %s", c) try: order_mgr.invalidate_holdings_cache() except Exception: pass # 유령(DB有·브로커0) — 동일 잔고 스냅샷으로 삭제 (추가 REST 없음) ghost = purge_ghost_active_trades(order_mgr, broker=broker) result["ghost_purged"] = list(ghost.get("purged") or []) result["ghost_purged_count"] = int(ghost.get("purged_count") or 0) if ghost.get("error"): result["ghost_error"] = ghost["error"] for f in ghost.get("failed") or []: result["failed"].append(f) result["reconciled_count"] = len(result["reconciled"]) result["failed_count"] = len(result["failed"]) logger.info( "🧩 [고아복구] 완료 — 복구 %d / 유령삭제 %d / 실패 %d / 보호(수동) %d / 이미추적 %d / 주문없음 %d", len(result["reconciled"]), result["ghost_purged_count"], len(result["failed"]), len(result["skipped_manual"]), len(result["skipped_tracked"]), len(result["skipped_no_order"]), ) return result