Files
kis_bot/kis_trader/execution/orphan_reconcile.py
Your Name eb03829e6e fix(orphan): is_mock 필터로 모의·실전 교차 고아복구 방지
브로커 잔고 대조 시 현재 KIS_MOCK과 active_trades.is_mock이 일치하는 행만 처리한다.
orders BUY 조회에 account_mode 필터를 추가해 계좌별 고아복구 정합을 맞춘다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:45:48 +09:00

823 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/execution/orphan_reconcile.py — 봇 고아 포지션 복구 (장마감 전·후 배치)
================================================================================
실계좌 잔고(inquire-balance, 페이징)와 active_trades·orders 를 대조해
「봇이 매수했는데 active_trades 미기록」 고아만 복구한다.
이어서 GHOST_PURGE_ON_RECONCILE 시 「DB有·브로커0」 유령 행을 삭제한다.
- 기동: 전략 쓰레드 전 1회 (ORPHAN_RECONCILE_ON_STARTUP, 기본 ON)
- 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
from .kis_client import (
broker_row_hldg_qty,
broker_row_sellable_qty,
cancelable_remainder_qty,
)
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),
("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, *, account_mode: str = "current") -> Set[str]:
"""orders 테이블에 BUY 기록이 있는 종목코드.
account_mode: current(현재 KIS_MOCK) | mock | live | all
"""
codes: Set[str] = set()
try:
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:
codes.add(c)
except Exception as e:
logger.warning("orders BUY 코드 조회 실패: %s", e)
return codes
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, account_mode=account_mode), parse_manual_hold_codes()
def _tracked_codes(db) -> Set[str]:
"""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 WHERE is_mock=%s",
(cur_mock,),
)
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 행 (복구 메타·체결가 참고). 현재 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 AND is_mock=%s
ORDER BY submitted_at DESC
LIMIT 1
""",
(code, mock_flag),
)
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:
"""보유수량. 없으면 매도가능(구맵). 매도가능 0이어도 보유>0 이면 0이 아님."""
br = (broker or {}).get(code) or (broker or {}).get(str(code).strip())
if not br:
return 0
hldg = broker_row_hldg_qty(br)
if hldg > 0:
return hldg
return broker_row_sellable_qty(br)
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],
is_mock: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""
같은 code+strategy(+is_mock) 에서 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")
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, is_mock "
"FROM trade_history "
"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, mock_flag, buy_ts)).fetchall() or []
else:
sql = (
"SELECT code, strategy, sell_reason, sell_date, buy_date, qty, is_mock "
"FROM trade_history "
"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, mock_flag, 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()
# 유령정리(0원) 이력은 재등록 금지·청산후삭제 트리거로 쓰지 않음
if reason.lower().startswith("ghost_purge"):
continue
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 청산 후 모의잔고 지연 → 고아복구가 다시 넣은 유령 정리)
현재 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, is_mock "
"FROM active_trades WHERE is_mock=%s",
(cur_mock,),
).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,
is_mock=cur_mock,
)
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주 → 유령 행 삭제.
- 현재 KIS_MOCK 과 같은 is_mock 행만 대상 (모의 보유를 실전 잔고로 지우지 않음).
- MANUAL_HOLD_CODES · strategy=HOLDING(장기) 은 보호(삭제 안 함).
- 잔고 API 실패 시 삭제하지 않음(오판 방지).
"""
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):
out["error"] = "실계좌 잔고 조회 실패"
return out
cancelable = None
if get_env_bool("INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST", True):
try:
cancelable = order_mgr.get_cancelable_sells(force=True)
except Exception as e:
logger.warning("유령정리 전 정정취소가능 조회 예외: %s", e)
cancelable = None
if cancelable is None:
out["error"] = "정정취소가능 조회 실패 — 유령삭제 스킵"
logger.warning("🧹 [유령잔고] 정정취소가능조회 실패 → 이번 회차 삭제 안 함")
return out
manual_hold = parse_manual_hold_codes()
try:
rows = db.conn.execute(
"SELECT code, name, strategy, current_qty, is_mock 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 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
# 장기 홀딩봇 포지션은 당일 EOD 대상이 아님 — 브로커 0이어도 수동 확인 전 보존
if strategy.upper() == "HOLDING":
out["skipped_holding"].append({"code": code, "name": name})
continue
if _broker_qty(broker, code) > 0:
continue
if cancelable_remainder_qty(cancelable or {}, code) > 0:
logger.info(
"⏸ [유령스킵] [%s] %s %s — 정정취소가능 매도 잔량 있음",
strategy, name, code,
)
continue
try:
pending = db.get_pending_sell_order(strategy, code)
except Exception:
pending = None
if pending:
logger.info(
"⏸ [유령스킵] [%s] %s %s — DB 미체결 매도 ODNO=%s",
strategy, name, code, pending.get("ord_no"),
)
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"])
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()
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)
cur_mock = _current_is_mock_flag()
try:
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)
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,
is_mock=_current_is_mock_flag(),
)
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 {},
"is_mock": _current_is_mock_flag(),
}
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