refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.
This commit is contained in:
@@ -229,6 +229,8 @@ class TradingOrchestrator:
|
||||
self._orphan_reconcile_date: str = ""
|
||||
# Pre-EOD 고아복구 중복 실행 가드 (당일 1회)
|
||||
self._orphan_pre_eod_reconcile_date: str = ""
|
||||
# 08:35~09:15 개장 전/초기 계좌 평단가·수량 동기화 가드 (당일 1회 — 권리락·액면분할 방어)
|
||||
self._morning_sync_date: str = ""
|
||||
# start_day_asset 조회가 모의 서버 500 등으로 실패할 때 무한 재시도 방지.
|
||||
# 다음 시도 가능 epoch (0 = 즉시 가능). 실패 시 N초 백오프.
|
||||
# 한투 모의 inquire-balance 가 간헐 500 → 20초 hb 마다 폭주하던 이슈 방지.
|
||||
@@ -551,6 +553,24 @@ class TradingOrchestrator:
|
||||
detail = self._universe_tag(sid)
|
||||
lines.append(f"{sid}: {src}")
|
||||
lines.append(f" └ {detail}")
|
||||
|
||||
ws_info = []
|
||||
if getattr(self, "kiwoom_ws", None) is not None:
|
||||
km = "모의" if getattr(self.kiwoom_ws, "is_mock", False) else "실전"
|
||||
ws_info.append(f"키움({km})")
|
||||
|
||||
kis_cache = None
|
||||
if getattr(self, "ws", None) is not None:
|
||||
kis_cache = getattr(self.ws, "ws_cache", None)
|
||||
|
||||
if kis_cache is not None:
|
||||
km = "모의" if getattr(kis_cache, "is_mock", False) else "실전"
|
||||
ws_info.append(f"KIS({km})")
|
||||
|
||||
if ws_info:
|
||||
lines.append("")
|
||||
lines.append(f"📡 [시세웹소켓] {', '.join(ws_info)}")
|
||||
|
||||
if startup_cash_line:
|
||||
lines.append("")
|
||||
prefix = "✅" if startup_cash_ok else "⚠️"
|
||||
@@ -608,6 +628,15 @@ class TradingOrchestrator:
|
||||
logger.info("🚀 kis_trader 통합 봇 시작")
|
||||
logger.info("=" * 70)
|
||||
|
||||
# KIS 접근토큰: 모의만 쓰면 실전이 방치되던 구멍 차단.
|
||||
# 세션미커버/만료분만 발급 (1일1회·EGW00133 준수). approval 6h 와 무관.
|
||||
try:
|
||||
from kis_token_manager import ensure_both_tokens
|
||||
|
||||
ensure_both_tokens()
|
||||
except Exception as e:
|
||||
logger.warning("KIS 토큰(실전+모의) 기동 점검 실패: %s", e)
|
||||
|
||||
# WS 허브 먼저 기동 (전략이 구독 요청하기 전에)
|
||||
ws_ok = self.ws.start()
|
||||
logger.info("WSManager active=%s", ws_ok)
|
||||
@@ -949,32 +978,14 @@ class TradingOrchestrator:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _start_ws_validator(self) -> None:
|
||||
"""키움 WS 기동 + (선택) KIS↔키움 가격 검증기.
|
||||
|
||||
- ``WS_PROVIDER=kis_with_validation`` : 검증기 + 키움 WS
|
||||
- ``WS_SUBSCRIBE_KIS_MINIMAL=true`` : KIS 구독 최소화용 키움 WS (검증기는 provider 에 따라)
|
||||
"""
|
||||
"""키움 WS 기동 + (선택) KIS↔키움 가격 검증기."""
|
||||
from .utils.env import get_env_from_db
|
||||
|
||||
minimal = get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False)
|
||||
provider = (get_env_from_db("WS_PROVIDER", "kis_only") or "kis_only").strip().lower()
|
||||
|
||||
need_kiwoom = bool(minimal or provider == "kis_with_validation")
|
||||
need_validator = provider == "kis_with_validation"
|
||||
|
||||
if not need_kiwoom:
|
||||
if provider not in ("kis_only", "kis_with_validation"):
|
||||
logger.warning(
|
||||
"⚠️ WS_PROVIDER='%s' 알 수 없음 → kis_only 로 취급",
|
||||
provider,
|
||||
)
|
||||
logger.info("ℹ️ 키움 WS 미기동 (WS_PROVIDER=kis_only & WS_SUBSCRIBE_KIS_MINIMAL=false)")
|
||||
return
|
||||
|
||||
if minimal and not need_validator:
|
||||
logger.info(
|
||||
"ℹ️ WS_SUBSCRIBE_KIS_MINIMAL=true → 키움 WS 기동 (WS_PROVIDER=kis_only: 검증기 비활성)",
|
||||
)
|
||||
need_validator = get_env_bool("LIVE_VALIDATOR_ENABLED", False)
|
||||
|
||||
# 키움 WS는 이제 틱/호가 프로바이더 및 적재 옵션에 따라 무조건 기동
|
||||
need_kiwoom = True
|
||||
|
||||
# 키움 키 로드 — 검증/분리 시세 모두 실키·실전 권장
|
||||
force_real_str = (get_env_from_db("KIWOOM_WS_FORCE_REAL", "true") or "true").strip().lower()
|
||||
@@ -1024,6 +1035,18 @@ class TradingOrchestrator:
|
||||
logger.warning("키움 WS 시작 실패")
|
||||
self.kiwoom_ws = None
|
||||
return
|
||||
else:
|
||||
# 🚀 키움 틱 적재 설정 적용
|
||||
tr = getattr(self.ws, "tick_recorder", None)
|
||||
if tr is not None and get_env_bool("WS_TICK_SAVE_KIWOOM", True):
|
||||
self.kiwoom_ws.attach_tick_recorder(tr)
|
||||
logger.info("✅ 키움 WS에 TickRecorder 부착 완료 (키움 틱 → ws_ticks 적재)")
|
||||
|
||||
# 🚀 키움 호가 적재 설정 적용
|
||||
tsr = getattr(self.ws, "trigger_snapshot_recorder", None)
|
||||
if tsr is not None and get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True):
|
||||
self.kiwoom_ws.attach_trigger_snapshot_recorder(tsr)
|
||||
logger.info("✅ 키움 WS에 TriggerSnapshotRecorder 부착 완료 (키움 호가 → ws_orderbook 적재)")
|
||||
except Exception as e:
|
||||
logger.warning("키움 WS 인스턴스 생성 실패: %s", e)
|
||||
self.kiwoom_ws = None
|
||||
@@ -1041,7 +1064,8 @@ class TradingOrchestrator:
|
||||
sync_kiwoom_to_kis=not minimal,
|
||||
)
|
||||
self.ws_validator.start()
|
||||
logger.info("🔬 [시세 검증] WS_PROVIDER=%s — ws_price_validation 기록", provider)
|
||||
self.ws_validator.start()
|
||||
logger.info("🔬 [시세 검증] LIVE_VALIDATOR_ENABLED=True — ws_price_validation 기록 (KIS↔키움 교차 검증)")
|
||||
except Exception as e:
|
||||
logger.warning("Validator 기동 실패: %s", e)
|
||||
self.ws_validator = None
|
||||
@@ -1079,11 +1103,9 @@ class TradingOrchestrator:
|
||||
"(갭보정·분봉은 키움)"
|
||||
)
|
||||
|
||||
force_real = get_env_bool("LS_WS_FORCE_REAL", True)
|
||||
try:
|
||||
row = self.db.conn.execute(
|
||||
"SELECT LS_APP_KEY_REAL, LS_APP_SECRET_REAL, "
|
||||
"LS_APP_KEY_MOCK, LS_APP_SECRET_MOCK "
|
||||
"SELECT LS_APP_KEY_REAL, LS_APP_SECRET_REAL "
|
||||
"FROM env_config ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
@@ -1093,14 +1115,9 @@ class TradingOrchestrator:
|
||||
logger.warning("env_config 없음 → LS WS 비활성")
|
||||
return
|
||||
r = dict(row)
|
||||
if force_real:
|
||||
app_key = (r.get("LS_APP_KEY_REAL") or "").strip()
|
||||
app_secret = (r.get("LS_APP_SECRET_REAL") or "").strip()
|
||||
is_mock = False
|
||||
else:
|
||||
app_key = (r.get("LS_APP_KEY_MOCK") or "").strip()
|
||||
app_secret = (r.get("LS_APP_SECRET_MOCK") or "").strip()
|
||||
is_mock = True
|
||||
app_key = (r.get("LS_APP_KEY_REAL") or "").strip()
|
||||
app_secret = (r.get("LS_APP_SECRET_REAL") or "").strip()
|
||||
is_mock = False
|
||||
if not app_key or not app_secret:
|
||||
logger.warning(
|
||||
"LS AppKey/Secret 미설정 → LS WS 비활성 "
|
||||
@@ -1130,6 +1147,11 @@ class TradingOrchestrator:
|
||||
chetime=str(payload.get("chetime") or ""),
|
||||
tr_cd=str(payload.get("tr_cd") or ""),
|
||||
)
|
||||
# 1b) 호가 틱동기 — 체결 1건당 RAM 호가 1장 (스냅 없으면 생략=실매와 동일)
|
||||
try:
|
||||
self._maybe_save_ls_orderbook_on_tick(code, payload)
|
||||
except Exception:
|
||||
pass
|
||||
# 2) 실매 get_recent_ticks 용 RAM 만 (기본).
|
||||
# ws_ticks 이중 INSERT 는 용량 낭비 → LS_WS_TICK_MIRROR_WS_TICKS=true 때만.
|
||||
tr = getattr(self.ws, "tick_recorder", None)
|
||||
@@ -1174,10 +1196,14 @@ class TradingOrchestrator:
|
||||
self.ls_ws.attach_orderbook_recorder(_on_orderbook)
|
||||
self.ls_ws.attach_vi_recorder(_on_vi)
|
||||
if also_hoga:
|
||||
_ob_mode = (
|
||||
get_env_from_db("LS_WS_ORDERBOOK_SAVE_MODE", "tick") or "tick"
|
||||
).strip().lower()
|
||||
logger.info(
|
||||
"LS WS 호가(UH1) 구독 ON → ls_ws_orderbook "
|
||||
"(save=%s gap_ms=%s)",
|
||||
"(save=%s mode=%s gap_ms=%s)",
|
||||
get_env_bool("LS_WS_ORDERBOOK_SAVE", True),
|
||||
_ob_mode,
|
||||
get_env_int("LS_WS_ORDERBOOK_SAVE_MS", 1000),
|
||||
)
|
||||
if get_env_bool("LS_WS_UVI_ENABLED", True):
|
||||
@@ -1220,7 +1246,7 @@ class TradingOrchestrator:
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"📡 [LS WS] 기동 — ticks/candles "
|
||||
"📡 [LS WS] 기동 — ticks/candles/hoga "
|
||||
"(VALIDATION=%s HISTORY=%s trade_ls=%s)",
|
||||
validation_on, history_on, trade_ls,
|
||||
)
|
||||
@@ -1234,6 +1260,78 @@ class TradingOrchestrator:
|
||||
self.ls_ws = None
|
||||
self.ls_ws_validator = None
|
||||
|
||||
def _maybe_save_ls_orderbook_on_tick(self, code: str, payload: dict) -> None:
|
||||
"""LS 체결 1건당 호가 RAM 스냅 1장 → ls_ws_orderbook (틱 동기).
|
||||
|
||||
스냅 없거나 만료면 저장 안 함 (OHLC·키움으로 메우지 않음 = 실매 필터와 동일).
|
||||
"""
|
||||
if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True):
|
||||
return
|
||||
mode = (
|
||||
get_env_from_db("LS_WS_ORDERBOOK_SAVE_MODE", "tick") or "tick"
|
||||
).strip().lower()
|
||||
if mode not in ("tick", "on_tick", "tick_sync", "sync"):
|
||||
return
|
||||
ls = getattr(self, "ls_ws", None)
|
||||
if ls is None:
|
||||
return
|
||||
max_age = float(get_env_float("LS_WS_ORDERBOOK_TICK_MAX_AGE_SEC", 3.0) or 3.0)
|
||||
getter = getattr(ls, "get_orderbook_snapshot", None)
|
||||
if not callable(getter):
|
||||
return
|
||||
snap = getter(code, max_age_sec=max_age)
|
||||
if snap is None:
|
||||
return
|
||||
if hasattr(snap, "to_storage_dict"):
|
||||
body = snap.to_storage_dict()
|
||||
elif isinstance(snap, dict):
|
||||
body = dict(snap)
|
||||
else:
|
||||
return
|
||||
# 틱 시각과 snap_time 정렬 (백테 pick ≤ 진입시각)
|
||||
snap_time = ""
|
||||
ts = payload.get("ts")
|
||||
if ts is not None:
|
||||
try:
|
||||
if hasattr(ts, "strftime"):
|
||||
snap_time = ts.strftime("%Y%m%d%H%M%S")
|
||||
else:
|
||||
s = str(ts).strip()
|
||||
digits = (
|
||||
s.replace("-", "")
|
||||
.replace(":", "")
|
||||
.replace(" ", "")
|
||||
.replace("T", "")
|
||||
.replace(".", "")
|
||||
)
|
||||
if len(digits) >= 14:
|
||||
snap_time = digits[:14]
|
||||
except Exception:
|
||||
snap_time = ""
|
||||
if len(snap_time) < 14:
|
||||
che = str(payload.get("chetime") or "").strip()
|
||||
che_d = "".join(ch for ch in che if ch.isdigit())
|
||||
if len(che_d) >= 6:
|
||||
from datetime import datetime as _dt
|
||||
day = _dt.now().strftime("%Y%m%d")
|
||||
snap_time = day + che_d[-6:].ljust(6, "0")[:6]
|
||||
if len(snap_time) >= 14:
|
||||
body["snap_time"] = snap_time[:14]
|
||||
body["source"] = str(body.get("source") or "ls_uh1")[:16]
|
||||
self.db.insert_ls_ws_orderbook(code=code, snap=body, market="KR")
|
||||
# 가끔 오래된 행 정리 (틱 폭주 대비)
|
||||
try:
|
||||
keep = int(get_env_int("LS_WS_ORDERBOOK_KEEP_DAYS", 7) or 7)
|
||||
if keep > 0 and hasattr(self.db, "cleanup_old_ls_ws_orderbook"):
|
||||
# 매 틱마다 DELETE 금지 — 몬otonic 스로틀
|
||||
now_m = time.monotonic()
|
||||
last = float(getattr(self, "_ls_ob_cleanup_mono", 0.0) or 0.0)
|
||||
if now_m - last >= 3600.0:
|
||||
self._ls_ob_cleanup_mono = now_m
|
||||
self.db.cleanup_old_ls_ws_orderbook(keep_days=keep)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _start_kiwoom_condition_manager(self) -> None:
|
||||
"""키움 조건검색 — 시세 WS(KiwoomWebSocketPriceCache) 와 단일 세션 공유.
|
||||
|
||||
@@ -1273,9 +1371,283 @@ class TradingOrchestrator:
|
||||
)
|
||||
if not self.kiwoom_condition_mgr.start():
|
||||
self.kiwoom_condition_mgr = None
|
||||
try:
|
||||
from .utils.ops_alert import ops_alert
|
||||
ops_alert(
|
||||
"kwcond_start_fail",
|
||||
"키움 조건검색 매니저 기동 실패",
|
||||
detail="kiwoom_condition 전략은 DB폴백·유니버스 위험",
|
||||
level="critical",
|
||||
session_only=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("키움 조건검색 매니저 기동 실패: %s", e)
|
||||
self.kiwoom_condition_mgr = None
|
||||
try:
|
||||
from .utils.ops_alert import ops_alert
|
||||
ops_alert(
|
||||
"kwcond_start_fail",
|
||||
"키움 조건검색 매니저 기동 예외",
|
||||
detail=str(e),
|
||||
level="critical",
|
||||
session_only=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _attach_kiwoom_condition_mgr_to_strategies(self) -> None:
|
||||
"""기동 실패→복구 시 전략이 들고 있던 None 핸들을 갱신."""
|
||||
mgr = self.kiwoom_condition_mgr
|
||||
for s in self.strategies:
|
||||
try:
|
||||
s.kiwoom_condition_mgr = mgr
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _maybe_recover_kiwoom_condition_manager(self) -> None:
|
||||
"""시세 WS LOGIN 후 토큰이 살아나면 조건검색을 재기동.
|
||||
|
||||
주말/야간 재시작 때 키움 토큰 JSON 파싱 실패로 매니저가 죽은 채
|
||||
시세만 나중에 붙으면 MOMENTUM/SHORT 가 DB sticky + 낡은 history
|
||||
교집합으로 유니버스 0이 된다. heartbeat 에서 복구한다.
|
||||
"""
|
||||
if self._stop:
|
||||
return
|
||||
if self.kiwoom_condition_mgr is not None:
|
||||
return
|
||||
if not (self._pending_kiwoom_condition_configs or []):
|
||||
return
|
||||
shared = self.kiwoom_ws
|
||||
if shared is None:
|
||||
return
|
||||
if not getattr(shared, "is_authenticated", lambda: False)():
|
||||
return
|
||||
now_m = time.time()
|
||||
last = float(getattr(self, "_kwcond_recover_mono", 0.0) or 0.0)
|
||||
interval = float(get_env_int("KIWOOM_COND_RECOVER_INTERVAL_SEC", 60))
|
||||
if now_m - last < max(15.0, interval):
|
||||
return
|
||||
self._kwcond_recover_mono = now_m
|
||||
logger.info(
|
||||
"🔄 키움 조건검색 매니저 복구 시도 "
|
||||
"(시세 WS LOGIN OK · 이전 기동 실패 후 재시도)"
|
||||
)
|
||||
try:
|
||||
self._start_kiwoom_condition_manager()
|
||||
except Exception as e:
|
||||
logger.warning("키움 조건검색 복구 예외: %s", e)
|
||||
return
|
||||
if self.kiwoom_condition_mgr is None:
|
||||
return
|
||||
self._attach_kiwoom_condition_mgr_to_strategies()
|
||||
logger.info(
|
||||
"✅ 키움 조건검색 매니저 복구 완료 → 전략 핸들 갱신 (%s)",
|
||||
self._mgr_heartbeat_desc(self.kiwoom_condition_mgr),
|
||||
)
|
||||
|
||||
def _ops_health_tick(self) -> None:
|
||||
"""장중 운영건강 — WS/유니버스/history/kwcond. 쿨다운은 ops_alert 내부."""
|
||||
try:
|
||||
from .utils.ops_alert import ops_alert, _in_kr_session
|
||||
except Exception:
|
||||
return
|
||||
if not _in_kr_session():
|
||||
return
|
||||
|
||||
# 1) 시세 WS
|
||||
try:
|
||||
if self.ws is not None and not getattr(self.ws, "is_active", False):
|
||||
ops_alert(
|
||||
"ws_kis_down",
|
||||
"KIS 시세 WS 비활성(IDLE)",
|
||||
detail="장중 is_active=False — REST fallback 가능하나 지연·누락 위험",
|
||||
level="critical",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
kw = self.kiwoom_ws
|
||||
if kw is not None and not getattr(kw, "is_authenticated", lambda: False)():
|
||||
ops_alert(
|
||||
"ws_kiwoom_down",
|
||||
"키움 시세 WS LOGIN 안 됨",
|
||||
detail="장중 미인증 — 조건검색·시세 위험",
|
||||
level="critical",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ls = getattr(self, "ls_ws", None)
|
||||
if ls is not None:
|
||||
opened = getattr(ls, "_opened", None)
|
||||
down = (
|
||||
opened is not None
|
||||
and hasattr(opened, "is_set")
|
||||
and not opened.is_set()
|
||||
)
|
||||
hold_need = max(15, get_env_int("OPS_ALERT_WS_DOWN_HOLD_SEC", 60))
|
||||
now_ls = time.time()
|
||||
if down:
|
||||
t0 = float(getattr(self, "_ls_ws_down_since", 0.0) or 0.0)
|
||||
if t0 <= 0:
|
||||
self._ls_ws_down_since = now_ls
|
||||
elif (now_ls - t0) >= hold_need:
|
||||
ops_alert(
|
||||
"ws_ls_down",
|
||||
"LS 시세 WS 미OPEN",
|
||||
detail=f"장중 LS 소켓 미연결 {int(now_ls - t0)}초+",
|
||||
level="critical",
|
||||
)
|
||||
# 쿨다운과 별도로 타이머 리셋(연속 스팸 방지)
|
||||
self._ls_ws_down_since = now_ls
|
||||
else:
|
||||
self._ls_ws_down_since = 0.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) kiwoom_condition 필요한데 매니저 없음
|
||||
need_kw = False
|
||||
for s in self.strategies:
|
||||
sid = str(getattr(s, "strategy_id", "") or "").upper()
|
||||
if not sid or sid.startswith("US_"):
|
||||
continue
|
||||
if self._resolve_source(sid) == "kiwoom_condition":
|
||||
need_kw = True
|
||||
break
|
||||
if need_kw and self.kiwoom_condition_mgr is None:
|
||||
ops_alert(
|
||||
"kwcond_off",
|
||||
"키움 조건검색 OFF (필요 전략 있음)",
|
||||
detail="MOMENTUM/SHORT 등 kiwoom_condition → DB폴백·매매0 위험",
|
||||
level="critical",
|
||||
)
|
||||
|
||||
# 3) 전략별 유니버스 0 지속
|
||||
zero_need = max(60, get_env_int("OPS_ALERT_UNIVERSE_ZERO_SEC", 180))
|
||||
now_m = time.time()
|
||||
zero_since = getattr(self, "_univ_zero_since", None)
|
||||
if not isinstance(zero_since, dict):
|
||||
zero_since = {}
|
||||
self._univ_zero_since = zero_since
|
||||
for s in self.strategies:
|
||||
sid = str(getattr(s, "strategy_id", "") or "").upper()
|
||||
if not sid or sid.startswith("US_"):
|
||||
continue
|
||||
try:
|
||||
n = 0
|
||||
src = self._resolve_source(sid)
|
||||
if src == "kiwoom_condition" and self.kiwoom_condition_mgr:
|
||||
n = len(self.kiwoom_condition_mgr.get_universe_for(sid) or [])
|
||||
elif src == "ls_condition" and self.ls_condition_mgr:
|
||||
n = len(self.ls_condition_mgr.get_universe_for(sid) or [])
|
||||
elif src == "condition" and self.condition_mgr:
|
||||
n = len(self.condition_mgr.get_universe_for(sid) or [])
|
||||
elif src == "ranking" and self.ranking_mgr:
|
||||
n = len(self.ranking_mgr.get_universe_for(sid) or [])
|
||||
else:
|
||||
continue
|
||||
if n <= 0:
|
||||
t0 = float(zero_since.get(sid) or 0.0)
|
||||
if t0 <= 0:
|
||||
zero_since[sid] = now_m
|
||||
elif (now_m - t0) >= zero_need:
|
||||
# 실매 소스 0 인데 다른 브로커에는 있으면 원인 힌트
|
||||
kw_n = -1
|
||||
ls_n = -1
|
||||
try:
|
||||
if self.kiwoom_condition_mgr:
|
||||
kw_n = len(
|
||||
self.kiwoom_condition_mgr.get_universe_for(sid)
|
||||
or []
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self.ls_condition_mgr:
|
||||
ls_n = len(
|
||||
self.ls_condition_mgr.get_universe_for(sid)
|
||||
or []
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
hint = ""
|
||||
if src == "ls_condition" and kw_n > 0 and ls_n <= 0:
|
||||
hint = (
|
||||
f" | 키움={kw_n} LS=0 → t1859공백/AFR미적재 "
|
||||
f"(LS_T1859_EMPTY_RETRY 확인)"
|
||||
)
|
||||
elif src == "kiwoom_condition" and ls_n > 0 and kw_n <= 0:
|
||||
hint = f" | LS={ls_n} 키움=0"
|
||||
ops_alert(
|
||||
"universe_zero",
|
||||
f"{sid} 유니버스 0 지속 {int(now_m - t0)}초",
|
||||
detail=f"source={src} kw={kw_n} ls={ls_n}{hint}",
|
||||
level="critical",
|
||||
)
|
||||
zero_since[sid] = now_m # 쿨다운과 별도로 타이머 리셋
|
||||
else:
|
||||
zero_since.pop(sid, None)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 4) 키움 history 당일 공백 (kiwoom_condition 전략)
|
||||
if need_kw and self.db is not None:
|
||||
stale_need = max(120, get_env_int("OPS_ALERT_HISTORY_STALE_SEC", 600))
|
||||
open_grace = max(0, get_env_int("OPS_ALERT_HISTORY_OPEN_GRACE_SEC", 180))
|
||||
try:
|
||||
# 개장 직후 N초는 history 첫 적재 레이스 — CRITICAL 스킵
|
||||
skip_open_race = False
|
||||
if open_grace > 0:
|
||||
start_hm = int(get_env_int("OPS_ALERT_SESSION_START_HM", 900) or 900)
|
||||
now_dt = dt.now()
|
||||
open_dt = now_dt.replace(
|
||||
hour=start_hm // 100,
|
||||
minute=start_hm % 100,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
if 0 <= (now_dt - open_dt).total_seconds() < float(open_grace):
|
||||
skip_open_race = True
|
||||
if not skip_open_race:
|
||||
today = dt.now().strftime("%Y-%m-%d")
|
||||
row = self.db.conn.execute(
|
||||
"""
|
||||
SELECT MAX(event_time) AS et
|
||||
FROM target_candidates_history
|
||||
WHERE strategy_id IN ('MOMENTUM','SHORT')
|
||||
AND event_time >= %s
|
||||
""",
|
||||
(today + " 00:00:00",),
|
||||
).fetchone()
|
||||
et = (row or {}).get("et") if row else None
|
||||
if not et:
|
||||
ops_alert(
|
||||
"history_stale",
|
||||
"키움 target_candidates_history 당일 0건",
|
||||
detail="조건검색 history 미적재 — 슬롯정합/백테 위험",
|
||||
level="critical",
|
||||
)
|
||||
else:
|
||||
if hasattr(et, "timestamp"):
|
||||
age = time.time() - float(et.timestamp())
|
||||
else:
|
||||
try:
|
||||
age = time.time() - dt.strptime(
|
||||
str(et)[:19], "%Y-%m-%d %H:%M:%S"
|
||||
).timestamp()
|
||||
except Exception:
|
||||
age = 0.0
|
||||
if age >= stale_need:
|
||||
ops_alert(
|
||||
"history_stale",
|
||||
f"키움 history 공백 {int(age)}초",
|
||||
detail=f"last_event={et}",
|
||||
level="critical",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("ops history check: %s", e)
|
||||
|
||||
def _start_ls_condition_manager(self) -> None:
|
||||
"""LS 서버저장조건(AFR) — 시세 get_price 와 무관.
|
||||
@@ -1429,13 +1801,7 @@ class TradingOrchestrator:
|
||||
|
||||
def _wire_ws_split_feed_if_needed(self) -> None:
|
||||
"""``WS_SUBSCRIBE_KIS_MINIMAL`` : 후보 틱→CandleAggregator + KIS/키움 분리 구독."""
|
||||
if not get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False):
|
||||
return
|
||||
if not self.kiwoom_ws:
|
||||
logger.warning(
|
||||
"WS_SUBSCRIBE_KIS_MINIMAL=true 이지만 키움 WS 미기동 — "
|
||||
"KIWOOM_APP_KEY_REAL 등 확인 (WS_PROVIDER=kis_with_validation 병행 권장)",
|
||||
)
|
||||
return
|
||||
ca = getattr(self.ws, "candle_agg", None)
|
||||
if not ca:
|
||||
@@ -1443,13 +1809,17 @@ class TradingOrchestrator:
|
||||
try:
|
||||
self.kiwoom_ws.attach_candle_aggregator(ca)
|
||||
tr = getattr(self.ws, "tick_recorder", None)
|
||||
if tr is not None:
|
||||
if tr is not None and get_env_bool("WS_TICK_SAVE_KIWOOM", True):
|
||||
self.kiwoom_ws.attach_tick_recorder(tr)
|
||||
tsr = getattr(self.ws, "trigger_snapshot_recorder", None)
|
||||
if tsr is not None:
|
||||
if tsr is not None and get_env_bool("WS_ORDERBOOK_SAVE_KIWOOM", True):
|
||||
self.kiwoom_ws.attach_trigger_snapshot_recorder(tsr)
|
||||
|
||||
# 무조건 WSManager에 키움 WS 인스턴스를 주입 (폴백/중복구독용)
|
||||
self.ws.set_kiwoom_ws(self.kiwoom_ws)
|
||||
self.ws.activate_split_feed(True)
|
||||
|
||||
if get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False):
|
||||
self.ws.activate_split_feed(True)
|
||||
except Exception as e:
|
||||
logger.warning("WS 분리 시세 연결 실패: %s", e)
|
||||
|
||||
@@ -1513,22 +1883,38 @@ class TradingOrchestrator:
|
||||
last_hb = 0.0
|
||||
last_daily_tick = 0.0
|
||||
last_pending_fill = 0.0
|
||||
last_kwcond_recover = 0.0
|
||||
pending_interval = float(get_env_int("PENDING_FILL_POLL_INTERVAL_SEC", 10))
|
||||
while not self._stop:
|
||||
now = time.time()
|
||||
# 키움 조건검색: 토큰/LOGIN 지연 시 60초마다 복구 시도
|
||||
if now - last_kwcond_recover >= 15:
|
||||
try:
|
||||
self._maybe_recover_kiwoom_condition_manager()
|
||||
except Exception as e:
|
||||
logger.debug("kwcond recover tick 예외: %s", e)
|
||||
last_kwcond_recover = now
|
||||
if now - last_hb >= 60:
|
||||
alive = [s.name for s in self.strategies if s.is_alive()]
|
||||
dead = [s.name for s in self.strategies if not s.is_alive()]
|
||||
ws_session = "OPEN" if self.ws.is_active else "IDLE"
|
||||
rank_desc = self._mgr_heartbeat_desc(self.ranking_mgr)
|
||||
cond_desc = self._mgr_heartbeat_desc(self.condition_mgr)
|
||||
kw_desc = self._mgr_heartbeat_desc(self.kiwoom_condition_mgr)
|
||||
ls_desc = self._mgr_heartbeat_desc(self.ls_condition_mgr)
|
||||
logger.info(
|
||||
"❤️ heartbeat ws=%s rank[%s] cond[%s] alive=%s dead=%s",
|
||||
ws_session, rank_desc, cond_desc, alive, dead,
|
||||
"❤️ heartbeat ws=%s rank[%s] cond[%s] kwcond[%s] lscond[%s] "
|
||||
"alive=%s dead=%s",
|
||||
ws_session, rank_desc, cond_desc, kw_desc, ls_desc,
|
||||
alive, dead,
|
||||
)
|
||||
if dead:
|
||||
logger.warning("⚠️ 죽은 전략 쓰레드 감지: %s → 재기동", dead)
|
||||
self._restart_dead()
|
||||
try:
|
||||
self._ops_health_tick()
|
||||
except Exception as e:
|
||||
logger.debug("ops_health_tick 예외: %s", e)
|
||||
last_hb = now
|
||||
if now - last_pending_fill >= pending_interval:
|
||||
try:
|
||||
@@ -1924,6 +2310,17 @@ class TradingOrchestrator:
|
||||
e, self._start_asset_backoff_sec,
|
||||
)
|
||||
|
||||
# 08:35~09:15 개장 전/초기 계좌 평단가·수량 동기화 (하루 1회 — 권리락/액면분할 방어)
|
||||
if (self._morning_sync_date != today) and ((h == 8 and m >= 35) or (h == 9 and m <= 15)):
|
||||
try:
|
||||
from .execution.orphan_reconcile import sync_active_trades_with_broker
|
||||
res = sync_active_trades_with_broker(self.order_mgr)
|
||||
self._morning_sync_date = today
|
||||
if res.get("synced"):
|
||||
logger.info("🌅 [개장 전/초기 계좌 동기화 완료] %d건 변경 감지 및 보정", len(res["synced"]))
|
||||
except Exception as e:
|
||||
logger.error("개장 전/초기 계좌 동기화 실패: %s", e)
|
||||
|
||||
# 09:00 장 시작 알림 (09:00~09:10 윈도우, 하루 1회)
|
||||
# 봇 기동 시점과 무관하게 매일 장 시작에 1회 발송 (아침 알람 용도).
|
||||
if (h == 9 and 0 <= m <= 10 and self._market_open_report_date != today):
|
||||
@@ -1984,7 +2381,8 @@ class TradingOrchestrator:
|
||||
g = int(result.get("ghost_purged_count") or 0)
|
||||
stale = int(result.get("stale_purged_count") or 0)
|
||||
skip_sell = int(result.get("skipped_after_sell_count") or 0)
|
||||
if n <= 0 and g <= 0 and stale <= 0 and skip_sell <= 0 and not result.get("error"):
|
||||
s = int(result.get("synced_count") or 0)
|
||||
if n <= 0 and g <= 0 and stale <= 0 and skip_sell <= 0 and s <= 0 and not result.get("error"):
|
||||
label = "Pre-EOD" if phase == "pre" else "장마감"
|
||||
logger.debug("🧩 [%s 고아복구] 복구·유령삭제 대상 없음", label)
|
||||
return
|
||||
@@ -1995,6 +2393,7 @@ class TradingOrchestrator:
|
||||
lines = [
|
||||
f"🧩 **[{title}]**",
|
||||
f"- 복구: {n}종목",
|
||||
f"- 계좌동기화: {s}종목",
|
||||
f"- 청산후스킵: {skip_sell}종목",
|
||||
f"- 청산후유령정리: {stale}종목",
|
||||
f"- 유령삭제: {g}종목",
|
||||
@@ -2016,6 +2415,10 @@ class TradingOrchestrator:
|
||||
lines.append(
|
||||
f" ✕청산후유령 {it.get('name')}({it.get('code')}) [{it.get('strategy')}]"
|
||||
)
|
||||
for it in (result.get("synced") or [])[:5]:
|
||||
lines.append(
|
||||
f" 🔄동기화 {it.get('name')}({it.get('code')}) [{it.get('strategy')}] {it.get('old_qty')}➔{it.get('new_qty')}주 ({it.get('old_avg'):,.0f}➔{it.get('new_avg'):,.0f}원)"
|
||||
)
|
||||
for it in (result.get("ghost_purged") or [])[:5]:
|
||||
lines.append(
|
||||
f" ✕유령 {it.get('name')}({it.get('code')}) [{it.get('strategy')}]"
|
||||
@@ -2027,8 +2430,8 @@ class TradingOrchestrator:
|
||||
except Exception as e:
|
||||
logger.debug("고아복구 MM 전송 실패: %s", e)
|
||||
logger.info(
|
||||
"🧩 [고아복구] 복구 %d · 청산후스킵 %d · 청산후유령정리 %d · 유령삭제 %d → active_trades 반영",
|
||||
n, skip_sell, stale, g,
|
||||
"🧩 [고아복구] 복구 %d · 계좌동기화 %d · 청산후스킵 %d · 청산후유령정리 %d · 유령삭제 %d → active_trades 반영",
|
||||
n, s, skip_sell, stale, g,
|
||||
)
|
||||
|
||||
def _build_market_open_report(self, today_ymd: str) -> str:
|
||||
@@ -2199,6 +2602,9 @@ class TradingOrchestrator:
|
||||
order_mgr=self.order_mgr,
|
||||
condition_mgr=self.condition_mgr,
|
||||
ranking_mgr=self.ranking_mgr,
|
||||
kiwoom_condition_mgr=self.kiwoom_condition_mgr,
|
||||
ls_condition_mgr=self.ls_condition_mgr,
|
||||
market_guard=self.market_guard,
|
||||
)
|
||||
if getattr(ns, "strategy_id", "") == "US_MOMENTUM" and self.overseas_ws:
|
||||
ns.overseas_ws = self.overseas_ws
|
||||
|
||||
Reference in New Issue
Block a user