diff --git a/kis_trader/execution/orphan_reconcile.py b/kis_trader/execution/orphan_reconcile.py index 7f0040e..3db0204 100644 --- a/kis_trader/execution/orphan_reconcile.py +++ b/kis_trader/execution/orphan_reconcile.py @@ -29,6 +29,27 @@ from .kis_client import ( logger = logging.getLogger("kis_trader.orphan_reconcile") + +def _current_is_mock_flag() -> int: + """현재 KIS_MOCK → 1=모의 · 0=실전. 브로커 대조는 이 모드 행만.""" + from database import TradeDB + return int(TradeDB.resolve_kis_is_mock(None)) + + +def _row_matches_current_account(is_mock_val: Any, current: Optional[int] = None) -> bool: + """active_trades.is_mock 이 현재 매매 모드와 같을 때만 브로커 대조 대상. + + NULL(미상)은 실전/모의 어느 쪽 잔고와도 맞춰 지우지 않음(오삭제 방지). + """ + cur = _current_is_mock_flag() if current is None else int(current) + if is_mock_val is None: + return False + try: + return int(is_mock_val) == cur + except (TypeError, ValueError): + return False + + # Pre-EOD 시각 산출 — (전략 ON 플래그, strategy_eod spec 키) _PRE_EOD_STRATEGY_FLAGS: Tuple[Tuple[str, str, bool], ...] = ( ("STRATEGY_BREAKOUT_ENABLED", "BREAKOUT", False), @@ -98,11 +119,28 @@ def parse_manual_hold_codes() -> Set[str]: } -def get_bot_bought_codes(db) -> Set[str]: - """orders 테이블에 BUY 기록이 있는 종목코드.""" +def get_bot_bought_codes(db, *, account_mode: str = "current") -> Set[str]: + """orders 테이블에 BUY 기록이 있는 종목코드. + + account_mode: current(현재 KIS_MOCK) | mock | live | all + """ codes: Set[str] = set() try: - cur = db.conn.execute("SELECT DISTINCT code FROM orders WHERE side='BUY'") + from database import TradeDB + mode = (account_mode or "current").strip().lower() + if mode in ("all", "both", "*"): + cur = db.conn.execute("SELECT DISTINCT code FROM orders WHERE side='BUY'") + else: + if mode in ("mock", "1", "true", "모의"): + flag = 1 + elif mode in ("live", "real", "0", "false", "실전"): + flag = 0 + else: + flag = int(TradeDB.resolve_kis_is_mock(None)) + cur = db.conn.execute( + "SELECT DISTINCT code FROM orders WHERE side='BUY' AND is_mock=%s", + (flag,), + ) for row in (cur.fetchall() or []): c = str(row.get("code") or "").strip() if c: @@ -112,16 +150,20 @@ def get_bot_bought_codes(db) -> Set[str]: return codes -def get_portfolio_origin_sets(db) -> Tuple[Set[str], Set[str]]: +def get_portfolio_origin_sets(db, account_mode: str = "current") -> Tuple[Set[str], Set[str]]: """(bot_bought_codes, manual_hold_codes) — backtest_web 보유·매도 탭과 동일.""" - return get_bot_bought_codes(db), parse_manual_hold_codes() + return get_bot_bought_codes(db, account_mode=account_mode), parse_manual_hold_codes() def _tracked_codes(db) -> Set[str]: - """active_trades 에 한 건이라도 있는 종목코드 (전 전략).""" + """active_trades 에 한 건이라도 있는 종목코드 — 현재 KIS_MOCK 행만.""" out: Set[str] = set() + cur_mock = _current_is_mock_flag() try: - cur = db.conn.execute("SELECT DISTINCT code FROM active_trades") + cur = db.conn.execute( + "SELECT DISTINCT code FROM active_trades WHERE is_mock=%s", + (cur_mock,), + ) for row in (cur.fetchall() or []): c = str(row.get("code") or "").strip() if c: @@ -132,16 +174,18 @@ def _tracked_codes(db) -> Set[str]: def _latest_buy_order(db, code: str) -> Optional[Dict[str, Any]]: - """해당 종목 최신 BUY orders 행 (복구 메타·체결가 참고).""" + """해당 종목 최신 BUY orders 행 (복구 메타·체결가 참고). 현재 KIS_MOCK만.""" try: + from database import TradeDB + mock_flag = int(TradeDB.resolve_kis_is_mock(None)) cur = db.conn.execute( """ SELECT * FROM orders - WHERE side='BUY' AND code=%s + WHERE side='BUY' AND code=%s AND is_mock=%s ORDER BY submitted_at DESC LIMIT 1 """, - (code,), + (code, mock_flag), ) row = cur.fetchone() return dict(row) if row else None @@ -207,9 +251,10 @@ def _find_sell_after_buy( code: str, strategy: str, buy_date: Optional[str], + is_mock: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ - 같은 code+strategy 에서 buy_date(없으면 당일) 이후 청산 히스토리 1건. + 같은 code+strategy(+is_mock) 에서 buy_date(없으면 당일) 이후 청산 히스토리 1건. ORPHAN_SKIP_SELL_REASONS 가 있으면 해당 사유만, 비면 모든 청산. """ if not get_env_bool("ORPHAN_SKIP_AFTER_SELL_HISTORY", True): @@ -221,24 +266,25 @@ def _find_sell_after_buy( reasons = _orphan_skip_sell_reasons() buy_ts = str(buy_date or "").strip() day = datetime.datetime.now().strftime("%Y-%m-%d") + mock_flag = _current_is_mock_flag() if is_mock is None else int(is_mock) try: if buy_ts: sql = ( - "SELECT code, strategy, sell_reason, sell_date, buy_date, qty " + "SELECT code, strategy, sell_reason, sell_date, buy_date, qty, is_mock " "FROM trade_history " - "WHERE code=%s AND strategy=%s AND sell_date IS NOT NULL " - "AND sell_date<>'' AND sell_date>=%s " + "WHERE code=%s AND strategy=%s AND is_mock=%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 [] + rows = db.conn.execute(sql, (code, sid, mock_flag, buy_ts)).fetchall() or [] else: sql = ( - "SELECT code, strategy, sell_reason, sell_date, buy_date, qty " + "SELECT code, strategy, sell_reason, sell_date, buy_date, qty, is_mock " "FROM trade_history " - "WHERE code=%s AND strategy=%s AND sell_date LIKE %s " + "WHERE code=%s AND strategy=%s AND is_mock=%s AND sell_date LIKE %s " "ORDER BY sell_date DESC LIMIT 20" ) - rows = db.conn.execute(sql, (code, sid, day + "%")).fetchall() or [] + rows = db.conn.execute(sql, (code, sid, mock_flag, day + "%")).fetchall() or [] except Exception as e: logger.warning("청산 히스토리 조회 실패 %s/%s: %s", sid, code, e) return None @@ -246,6 +292,9 @@ def _find_sell_after_buy( for row in rows: d = dict(row) reason = str(d.get("sell_reason") or "").strip() + # 유령정리(0원) 이력은 재등록 금지·청산후삭제 트리거로 쓰지 않음 + if reason.lower().startswith("ghost_purge"): + continue if reasons is None: return d reason_l = reason.lower() @@ -259,14 +308,18 @@ def purge_active_after_sell_history(db) -> Dict[str, Any]: """ active_trades 행의 buy_date 이후에 같은 전략 청산 히스토리가 있으면 DB에서 제거. (EOD 청산 후 모의잔고 지연 → 고아복구가 다시 넣은 유령 정리) + 현재 KIS_MOCK 과 같은 is_mock 행만 대상. """ 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 + cur_mock = _current_is_mock_flag() try: rows = db.conn.execute( - "SELECT code, name, strategy, current_qty, buy_date FROM active_trades" + "SELECT code, name, strategy, current_qty, buy_date, is_mock " + "FROM active_trades WHERE is_mock=%s", + (cur_mock,), ).fetchall() or [] except Exception as e: out["error"] = str(e) @@ -288,6 +341,7 @@ def purge_active_after_sell_history(db) -> Dict[str, Any]: code=code, strategy=strategy, buy_date=str(d.get("buy_date") or "") or None, + is_mock=cur_mock, ) if not hit: continue @@ -320,15 +374,20 @@ def purge_ghost_active_trades(order_mgr, broker: Optional[Dict[str, Any]] = None """ DB(active_trades) 有 · 브로커 0주 → 유령 행 삭제. + - 현재 KIS_MOCK 과 같은 is_mock 행만 대상 (모의 보유를 실전 잔고로 지우지 않음). - MANUAL_HOLD_CODES · strategy=HOLDING(장기) 은 보호(삭제 안 함). - 잔고 API 실패 시 삭제하지 않음(오판 방지). """ - out: Dict[str, Any] = {"purged": [], "skipped_manual": [], "skipped_holding": [], "failed": []} + out: Dict[str, Any] = { + "purged": [], "skipped_manual": [], "skipped_holding": [], + "skipped_other_account": [], "failed": [], + } if not get_env_bool("GHOST_PURGE_ON_RECONCILE", True): out["msg"] = "GHOST_PURGE_ON_RECONCILE=false" return out db = order_mgr.db + cur_mock = _current_is_mock_flag() if broker is None: broker = order_mgr.get_broker_holdings(force=True) if not getattr(order_mgr, "_holdings_last_fetch_ok", False): @@ -350,7 +409,7 @@ def purge_ghost_active_trades(order_mgr, broker: Optional[Dict[str, Any]] = None manual_hold = parse_manual_hold_codes() try: rows = db.conn.execute( - "SELECT code, name, strategy, current_qty FROM active_trades" + "SELECT code, name, strategy, current_qty, is_mock FROM active_trades" ).fetchall() or [] except Exception as e: out["error"] = str(e) @@ -364,6 +423,12 @@ def purge_ghost_active_trades(order_mgr, broker: Optional[Dict[str, Any]] = None continue name = str(d.get("name") or code) strategy = str(d.get("strategy") or "").strip() + if not _row_matches_current_account(d.get("is_mock"), cur_mock): + out["skipped_other_account"].append({ + "code": code, "name": name, "strategy": strategy, + "is_mock": d.get("is_mock"), + }) + continue if code in manual_hold: out["skipped_manual"].append({"code": code, "name": name, "strategy": strategy}) continue @@ -420,6 +485,12 @@ def purge_ghost_active_trades(order_mgr, broker: Optional[Dict[str, Any]] = None out["purged_count"] = len(out["purged"]) out["failed_count"] = len(out["failed"]) + out["skipped_other_account_count"] = len(out["skipped_other_account"]) + if out["skipped_other_account"]: + logger.info( + "🧹 [유령스킵] 다른 계좌모드(is_mock≠현재=%s) %d건 — 삭제 안 함", + cur_mock, len(out["skipped_other_account"]), + ) if out["purged"]: try: order_mgr.invalidate_holdings_cache() @@ -450,9 +521,13 @@ def sync_active_trades_with_broker(order_mgr, broker: Optional[Dict[str, Any]] = manual_hold = parse_manual_hold_codes() min_diff_px = get_env_float("SYNC_PRICE_DIFF_MIN", 0.5) - + cur_mock = _current_is_mock_flag() + try: - rows = db.conn.execute("SELECT * FROM active_trades").fetchall() or [] + rows = db.conn.execute( + "SELECT * FROM active_trades WHERE is_mock=%s", + (cur_mock,), + ).fetchall() or [] except Exception as e: out["error"] = str(e) logger.warning("🔄 [계좌동기화] active_trades 조회 실패: %s", e) @@ -634,6 +709,7 @@ def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]: # (모의·잔고 API 지연으로 브로커 qty>0 이어도 EOD 후 유령 복구 방지) closed = _find_sell_after_buy( db, code=c, strategy=strategy, buy_date=buy_date, + is_mock=_current_is_mock_flag(), ) if closed: result["skipped_after_sell"].append({ @@ -681,6 +757,7 @@ def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]: "buy_date": buy_date, "size_class": meta.get("size_class") or "", "entry_features": meta.get("entry_features") or {}, + "is_mock": _current_is_mock_flag(), } try: