572 lines
21 KiB
Python
572 lines
21 KiB
Python
"""
|
||
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 _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 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": [],
|
||
}
|
||
|
||
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
|
||
|
||
# 유령(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",
|
||
len(result["reconciled"]),
|
||
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
|