refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.

This commit is contained in:
Your Name
2026-08-12 10:19:19 +09:00
parent cb7e5037a0
commit c6bd62a25f
218 changed files with 31613 additions and 759 deletions

View File

@@ -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