feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
@@ -1,23 +1,88 @@
|
||||
"""
|
||||
kis_trader/execution/orphan_reconcile.py — 봇 고아 포지션 복구 (장마감 후 배치)
|
||||
kis_trader/execution/orphan_reconcile.py — 봇 고아 포지션 복구 (장마감 전·후 배치)
|
||||
================================================================================
|
||||
실계좌 잔고(inquire-balance, 페이징)와 active_trades·orders 를 대조해
|
||||
「봇이 매수했는데 active_trades 미기록」 고아만 복구한다.
|
||||
이어서 GHOST_PURGE_ON_RECONCILE 시 「DB有·브로커0」 유령 행을 삭제한다.
|
||||
|
||||
- 수동매수(orders BUY 없음) · MANUAL_HOLD_CODES 지정분은 건드리지 않음.
|
||||
- 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 ..utils.env import get_env_bool, get_env_from_db
|
||||
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 — 쉼표/세미콜론 구분 종목코드."""
|
||||
@@ -101,12 +166,95 @@ def _num(v, default: float = 0.0) -> float:
|
||||
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:
|
||||
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),
|
||||
})
|
||||
logger.warning(
|
||||
"🧹 [유령잔고삭제] [%s] %s %s — 브로커 0주 → active_trades 삭제",
|
||||
strategy, name, code,
|
||||
)
|
||||
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
|
||||
reconciled, skipped_tracked, skipped_manual, skipped_no_order, failed,
|
||||
ghost_purged, ghost_purged_count
|
||||
"""
|
||||
result: Dict[str, Any] = {
|
||||
"reconciled": [],
|
||||
@@ -114,6 +262,7 @@ def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]:
|
||||
"skipped_manual": [],
|
||||
"skipped_no_order": [],
|
||||
"failed": [],
|
||||
"ghost_purged": [],
|
||||
}
|
||||
|
||||
if not get_env_bool("ORPHAN_RECONCILE_ENABLED", True):
|
||||
@@ -214,11 +363,21 @@ def reconcile_orphan_positions(order_mgr) -> Dict[str, Any]:
|
||||
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 / 유령삭제 %d / 실패 %d / 보호(수동) %d / 이미추적 %d / 주문없음 %d",
|
||||
len(result["reconciled"]),
|
||||
result["ghost_purged_count"],
|
||||
len(result["failed"]),
|
||||
len(result["skipped_manual"]),
|
||||
len(result["skipped_tracked"]),
|
||||
|
||||
Reference in New Issue
Block a user