""" kis_trader/execution/orphan_reconcile.py — 봇 고아 포지션 복구 (장마감 후 배치) ================================================================================ 실계좌 잔고(inquire-balance, 페이징)와 active_trades·orders 를 대조해 「봇이 매수했는데 active_trades 미기록」 고아만 복구한다. - 수동매수(orders BUY 없음) · MANUAL_HOLD_CODES 지정분은 건드리지 않음. - 매매 알림은 보내지 않음(로그 + 선택적 요약 MM). """ from __future__ import annotations import json import logging from typing import Any, Dict, List, Optional, Set, Tuple from ..utils.env import get_env_bool, get_env_from_db from ..utils.strategy_ids import canonical_strategy_id logger = logging.getLogger("kis_trader.orphan_reconcile") 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 reconcile_orphan_positions(order_mgr) -> Dict[str, Any]: """ 장마감 후 1회 호출 — 봇 고아만 active_trades 에 upsert. Returns: reconciled, skipped_tracked, skipped_manual, skipped_no_order, failed """ result: Dict[str, Any] = { "reconciled": [], "skipped_tracked": [], "skipped_manual": [], "skipped_no_order": [], "failed": [], } 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 result["reconciled_count"] = len(result["reconciled"]) result["failed_count"] = len(result["failed"]) logger.info( "🧩 [고아복구] 완료 — 복구 %d / 실패 %d / 보호(수동) %d / 이미추적 %d / 주문없음 %d", len(result["reconciled"]), len(result["failed"]), len(result["skipped_manual"]), len(result["skipped_tracked"]), len(result["skipped_no_order"]), ) return result