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