""" 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_float, 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 _orphan_skip_sell_reasons() -> Optional[List[str]]: """ 재등록 금지 sell_reason 키워드 목록. env 비우면 None → 매수 이후 **모든** 청산 히스토리를 재등록 금지로 본다. 기본: 장마감청산·eod (모의잔고 지연으로 EOD 후 고아복구가 유령 재등록하던 케이스). """ raw = str( get_env_from_db( "ORPHAN_SKIP_SELL_REASONS", "장마감청산,eod", ) or "" ).strip() if not raw: return None return [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()] def _find_sell_after_buy( db, *, code: str, strategy: str, buy_date: Optional[str], ) -> Optional[Dict[str, Any]]: """ 같은 code+strategy 에서 buy_date(없으면 당일) 이후 청산 히스토리 1건. ORPHAN_SKIP_SELL_REASONS 가 있으면 해당 사유만, 비면 모든 청산. """ if not get_env_bool("ORPHAN_SKIP_AFTER_SELL_HISTORY", True): return None code = str(code or "").strip() sid = canonical_strategy_id(str(strategy or "").strip() or "SCALP") if not code: return None reasons = _orphan_skip_sell_reasons() buy_ts = str(buy_date or "").strip() day = datetime.datetime.now().strftime("%Y-%m-%d") try: if buy_ts: sql = ( "SELECT code, strategy, sell_reason, sell_date, buy_date, qty " "FROM trade_history " "WHERE code=%s AND strategy=%s AND sell_date IS NOT NULL " "AND sell_date<>'' AND sell_date>=%s " "ORDER BY sell_date DESC LIMIT 20" ) rows = db.conn.execute(sql, (code, sid, buy_ts)).fetchall() or [] else: sql = ( "SELECT code, strategy, sell_reason, sell_date, buy_date, qty " "FROM trade_history " "WHERE code=%s AND strategy=%s AND sell_date LIKE %s " "ORDER BY sell_date DESC LIMIT 20" ) rows = db.conn.execute(sql, (code, sid, day + "%")).fetchall() or [] except Exception as e: logger.warning("청산 히스토리 조회 실패 %s/%s: %s", sid, code, e) return None for row in rows: d = dict(row) reason = str(d.get("sell_reason") or "").strip() if reasons is None: return d reason_l = reason.lower() for kw in reasons: if kw.lower() in reason_l or kw in reason: return d return None def purge_active_after_sell_history(db) -> Dict[str, Any]: """ active_trades 행의 buy_date 이후에 같은 전략 청산 히스토리가 있으면 DB에서 제거. (EOD 청산 후 모의잔고 지연 → 고아복구가 다시 넣은 유령 정리) """ out: Dict[str, Any] = {"purged": [], "failed": [], "skipped": []} if not get_env_bool("ORPHAN_PURGE_ACTIVE_AFTER_SELL_HISTORY", True): out["msg"] = "ORPHAN_PURGE_ACTIVE_AFTER_SELL_HISTORY=false" return out try: rows = db.conn.execute( "SELECT code, name, strategy, current_qty, buy_date FROM active_trades" ).fetchall() or [] except Exception as e: out["error"] = str(e) return out manual_hold = parse_manual_hold_codes() for row in rows: d = dict(row) code = str(d.get("code") or "").strip() strategy = str(d.get("strategy") or "").strip() name = str(d.get("name") or code) if not code: continue if code in manual_hold or strategy.upper() == "HOLDING": out["skipped"].append({"code": code, "strategy": strategy, "why": "protected"}) continue hit = _find_sell_after_buy( db, code=code, strategy=strategy, buy_date=str(d.get("buy_date") or "") or None, ) if not hit: 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), "sell_reason": hit.get("sell_reason"), "sell_date": hit.get("sell_date"), }) logger.warning( "🧹 [청산후유령정리] [%s] %s %s — 히스토리 %s @%s → active 삭제", strategy, name, code, hit.get("sell_reason"), hit.get("sell_date"), ) except Exception as e: out["failed"].append({"code": code, "strategy": strategy, "error": str(e)}) logger.exception("청산후 유령 active 삭제 실패 %s", code) out["purged_count"] = len(out["purged"]) return out 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: # OrderManager 와 동일: trade_history 에 ghost_purge 남긴 뒤 정리 recorded = False if hasattr(order_mgr, "_record_ghost_purge_history"): recorded = bool( order_mgr._record_ghost_purge_history( code, strategy or "", sell_reason="ghost_purge:broker_zero", ) ) else: 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), "history_recorded": recorded, }) logger.warning( "🧹 [유령잔고삭제] [%s] %s %s — 브로커 0주 → 정리%s", strategy, name, code, " (trade_history 기록)" if recorded else "", ) 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 sync_active_trades_with_broker(order_mgr, broker: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ 개장 전(08:35~09:15) 또는 고아복구 시 DB(active_trades)와 증권사 실계좌 잔고를 동기화. - 기업 이벤트(액면분할, 무상증자 등) 또는 수기 매입으로 계좌 매수단가/수량이 변경된 경우, DB의 avg_buy_price, current_qty, stop_price, target_price, max_price 를 비율에 맞춰 보정(Sync). - 오진입 손절 및 트레이링 스톱 착시 방지. """ out: Dict[str, Any] = {"synced": [], "failed": []} if not get_env_bool("ORPHAN_RECONCILE_ENABLED", True): out["msg"] = "ORPHAN_RECONCILE_ENABLED=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"] = "실계좌 잔고 조회 실패 — 안전을 위해 동기화 스킵" logger.warning("🔄 [계좌동기화] 실계좌 잔고 조회 실패 — 안전을 위해 스킵") return out manual_hold = parse_manual_hold_codes() min_diff_px = get_env_float("SYNC_PRICE_DIFF_MIN", 0.5) try: rows = db.conn.execute("SELECT * FROM active_trades").fetchall() or [] except Exception as e: out["error"] = str(e) logger.warning("🔄 [계좌동기화] active_trades 조회 실패: %s", e) return out now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 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 or strategy.upper() == "HOLDING": continue br = broker.get(code) if not br: # 브로커에 0주인 종목은 purge_ghost_active_trades 가 처리하므로 여기선 스킵 continue br_qty = int(br.get("qty") or 0) br_avg = float(br.get("avg_price") or 0.0) db_qty = int(d.get("current_qty") or 0) db_avg = float(d.get("avg_buy_price") or 0.0) if br_qty <= 0 or br_avg <= 0: continue qty_diff = (br_qty != db_qty) price_diff = abs(br_avg - db_avg) >= min_diff_px if not (qty_diff or price_diff): continue # 단가/수량 변경 감지! 비율(ratio) 계산하여 손절가·익절가·고점(max_price) 비례 조정 ratio = (br_avg / db_avg) if (db_avg > 0) else 1.0 old_stop = float(d.get("stop_price") or 0.0) old_target = float(d.get("target_price") or 0.0) old_max = float(d.get("max_price") or 0.0) new_stop = round(old_stop * ratio, 2) if old_stop > 0 else 0.0 new_target = round(old_target * ratio, 2) if old_target > 0 else 0.0 new_max = max(br_avg, round(old_max * ratio, 2)) if old_max > 0 else br_avg new_inv = br_avg * br_qty try: if strategy: db.conn.execute( "UPDATE active_trades SET avg_buy_price=%s, current_qty=%s, target_qty=%s, " "stop_price=%s, target_price=%s, max_price=%s, total_invested=%s, updated_at=%s " "WHERE code=%s AND strategy=%s", (br_avg, br_qty, br_qty, new_stop, new_target, new_max, new_inv, now_str, code, strategy) ) else: db.conn.execute( "UPDATE active_trades SET avg_buy_price=%s, current_qty=%s, target_qty=%s, " "stop_price=%s, target_price=%s, max_price=%s, total_invested=%s, updated_at=%s " "WHERE code=%s", (br_avg, br_qty, br_qty, new_stop, new_target, new_max, new_inv, now_str, code) ) msg = ( f"🔄 [계좌 싱크 로봇] 원장 변동 동기화! {name}({code}) [{strategy}]\n" f" · 수량: {db_qty}주 ➔ {br_qty}주\n" f" · 매입단가: {db_avg:,.0f}원 ➔ {br_avg:,.0f}원 (비율 {ratio:.4f})\n" f" · 손절가 보정: {old_stop:,.0f}원 ➔ {new_stop:,.0f}원\n" f" · 고점(max) 보정: {old_max:,.0f}원 ➔ {new_max:,.0f}원" ) logger.info(msg) out["synced"].append({ "code": code, "name": name, "strategy": strategy, "old_qty": db_qty, "new_qty": br_qty, "old_avg": db_avg, "new_avg": br_avg, }) # 알림 발송 if hasattr(order_mgr, "notifier") and order_mgr.notifier and hasattr(order_mgr.notifier, "send_message"): try: order_mgr.notifier.send_message(msg) except Exception: pass elif hasattr(order_mgr, "send_telegram_msg"): try: order_mgr.send_telegram_msg(msg) except Exception: pass except Exception as e: out["failed"].append({"code": code, "name": name, "error": str(e)}) logger.exception("🔄 [계좌동기화] UPDATE 실패 %s", code) if out["synced"]: try: order_mgr.invalidate_holdings_cache() except Exception: pass out["synced_count"] = len(out["synced"]) else: out["synced_count"] = 0 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, skipped_after_sell, failed, ghost_purged, ghost_purged_count, stale_purged, stale_purged_count """ result: Dict[str, Any] = { "reconciled": [], "skipped_tracked": [], "skipped_manual": [], "skipped_no_order": [], "skipped_after_sell": [], "failed": [], "ghost_purged": [], "stale_purged": [], "synced": [], } 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 # EOD 청산 후 잘못 다시 들어간 active 유령부터 정리 stale = purge_active_after_sell_history(db) result["stale_purged"] = list(stale.get("purged") or []) result["stale_purged_count"] = int(stale.get("purged_count") or 0) if stale.get("error"): result["stale_error"] = stale["error"] 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") ) buy_date = ( str(order_row.get("filled_at") or order_row.get("submitted_at") or "").strip() or None ) # 당일/매수 이후 장마감청산 등 히스토리 있으면 재등록 금지 # (모의·잔고 API 지연으로 브로커 qty>0 이어도 EOD 후 유령 복구 방지) closed = _find_sell_after_buy( db, code=c, strategy=strategy, buy_date=buy_date, ) if closed: result["skipped_after_sell"].append({ "code": c, "name": name, "qty": qty, "strategy": strategy, "sell_reason": closed.get("sell_reason"), "sell_date": closed.get("sell_date"), }) logger.info( "⏭ [고아복구스킵] %s(%s) [%s] — 이미 청산(%s @%s), 재등록 안 함", name, c, strategy, closed.get("sell_reason"), closed.get("sell_date"), ) continue 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 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 # 실계좌 평단가·수량 동기화 (액면조정·수기매입 등 보정) sync = sync_active_trades_with_broker(order_mgr, broker=broker) result["synced"] = list(sync.get("synced") or []) result["synced_count"] = int(sync.get("synced_count") or 0) if sync.get("error"): result["sync_error"] = sync["error"] for f in sync.get("failed") or []: result["failed"].append(f) # 유령(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("ghost_purged_count") or 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"]) result["skipped_after_sell_count"] = len(result["skipped_after_sell"]) logger.info( "🧩 [고아복구] 완료 — 복구 %d / 단가·수량동기화 %d / 청산후스킵 %d / 청산후유령정리 %d / " "유령삭제 %d / 실패 %d / 보호(수동) %d / 이미추적 %d / 주문없음 %d", len(result["reconciled"]), result["synced_count"], len(result["skipped_after_sell"]), result["stale_purged_count"], result["ghost_purged_count"], len(result["failed"]), len(result["skipped_manual"]), len(result["skipped_tracked"]), len(result["skipped_no_order"]), ) return result