Files
kis_bot/kis_trader/execution/orphan_reconcile.py
Your Name 61bec4bd1d feat: Add DART strategy and related configurations
ㅇ
Changes:
- Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework.
- Updated the database schema to include DART-specific tables for disclosures and watchlists.
- Enhanced the backtesting and parameter search functionalities to support the DART strategy.
- Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy.

Impact:
- These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
2026-07-21 07:50:24 +09:00

400 lines
14 KiB
Python
Raw 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」 유령 행을 삭제한다.
- 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 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, failed,
ghost_purged, ghost_purged_count
"""
result: Dict[str, Any] = {
"reconciled": [],
"skipped_tracked": [],
"skipped_manual": [],
"skipped_no_order": [],
"failed": [],
"ghost_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
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")
)
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
buy_date = (
str(order_row.get("filled_at") or order_row.get("submitted_at") or "").strip()
or None
)
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("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"])
logger.info(
"🧩 [고아복구] 완료 — 복구 %d / 유령삭제 %d / 실패 %d / 보호(수동) %d / 이미추적 %d / 주문없음 %d",
len(result["reconciled"]),
result["ghost_purged_count"],
len(result["failed"]),
len(result["skipped_manual"]),
len(result["skipped_tracked"]),
len(result["skipped_no_order"]),
)
return result