fix(orphan): is_mock 필터로 모의·실전 교차 고아복구 방지

브로커 잔고 대조 시 현재 KIS_MOCK과 active_trades.is_mock이 일치하는 행만 처리한다.
orders BUY 조회에 account_mode 필터를 추가해 계좌별 고아복구 정합을 맞춘다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-08-28 16:45:48 +09:00
parent 2c915c9508
commit eb03829e6e

View File

@@ -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: