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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user