""" kis_trader/main.py — 통합 오케스트레이터 ========================================== 구조: [공유 인프라] KISClient, TradeDBExt, WSManager, OrderManager [전략 쓰레드] ScalpingStrategy(독립 Thread), TailCatchStrategy(독립 Thread) 전략 스위치 (DB env_config 또는 OS env): - ``STRATEGY_SCALP_ENABLED`` (기본 true) — SCALP=reversal(고정) - ``STRATEGY_SHORT_ENABLED`` (기본 true) - ``STRATEGY_BREAKOUT_ENABLED`` (기본 false) - ``STRATEGY_MOMENTUM_ENABLED`` (기본 false) — 키움 'scalp' 조건검색 추종(추세 전략) - ``STRATEGY_UPDOW_ENABLED`` (기본 false) — ``holding_stock_config`` 관심종목만 직전봉 몸통 하락·다음봉 시가 매수 - ``STRATEGY_SAME_CODE_POLICY`` (allow|block, 기본 allow — OrderManager 에서 사용) 유니버스 소스 스위치 (전략별, 런타임 교체 가능): - ``SCALP_UNIVERSE_SOURCE`` (기본 "condition") - ``SHORT_UNIVERSE_SOURCE`` (기본 "kiwoom_condition") - ``BREAKOUT_UNIVERSE_SOURCE`` (기본 "condition") - ``MOMENTUM_UNIVERSE_SOURCE`` (기본 "kiwoom_condition" — ranking | condition | kiwoom_condition) - ``UPDOW_UNIVERSE_SOURCE`` (기본 "condition") 값: "ranking" | "condition" ── "ranking" (KIS 거래량/체결강도 상위 REST) ── - ``RANKING_POLL_INTERVAL_SEC`` : 폴링 주기 (기본 10) - 전략별 세부 설정: SCALP : ``RANK_SCALP_SORT`` (기본 volume) / ``RANK_SCALP_LIMIT`` (기본 100) SHORT : ``RANK_SHORT_SORT`` (기본 volume) / ``RANK_SHORT_LIMIT`` (기본 100) BREAKOUT : ``RANK_BREAKOUT_SORT`` (기본 trading_value) / ``RANK_BREAKOUT_LIMIT`` (기본 100) MOMENTUM : ``RANK_MOMENTUM_SORT`` (기본 volume) / ``RANK_MOMENTUM_LIMIT`` (기본 20) UPDOW : ``RANK_UPDOW_SORT`` (기본 volume) / ``RANK_UPDOW_LIMIT`` (기본 100) - sort 값: volume | trading_value | strength | fluct_up | fluct_down ── "condition" (HTS 조건식 REST) ── - ``KIS_HTS_ID`` : 조건식이 저장된 HTS 로그인 ID (필수) - ``CONDITION_POLL_INTERVAL_SEC``: 폴링 주기 초 (기본 10) - 전략별: SCALP : ``CONDITION_SCALP_NAME`` / ``CONDITION_SCALP_SEQ`` SHORT : ``CONDITION_SHORT_NAME`` / ``CONDITION_SHORT_SEQ`` BREAKOUT: ``CONDITION_BREAKOUT_NAME`` / ``CONDITION_BREAKOUT_SEQ`` MOMENTUM: ``CONDITION_MOMENTUM_NAME`` / ``CONDITION_MOMENTUM_SEQ`` UPDOW : ``CONDITION_UPDOW_NAME`` / ``CONDITION_UPDOW_SEQ`` 공통: - ``UNIVERSE_HISTORY_SAVE`` : 변동 감지 tick 마다 target_candidates_history 에 초단위 event_time 으로 **즉시** INSERT (기본 true). 버퍼/배치 flush 없음. 실행: $ cd ~/kis_bot $ python -m kis_trader.main """ from __future__ import annotations import os import signal import sys import time from datetime import datetime as dt from typing import Any, Dict, List, Optional, Tuple # 패키지로 실행되지 않은 경우(스크립트 직접 실행) sys.path 보정 if __package__ in (None, ""): _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) __package__ = "kis_trader" # noqa: F841 from .engine.daily_profit_halt import DailyProfitHaltGuard from .database.db_manager import get_db from .execution.kis_client import KISClient from .execution.account_cash import AccountCashLedger from .execution.order_manager import OrderManager from .network.condition_manager import ConditionSearchManager from .network.kiwoom_condition_manager import KiwoomConditionSearchManager from .network.ls_condition_manager import ( DEFAULT_SID_TO_LS_NAME, LsConditionSearchManager, ) from .network.market_guard import MarketGuard from .network.ranking_manager import VolumeRankManager from .network.ws_manager import WSManager from .strategies import ( DbBandStrategy, DartStrategy, BreakoutStrategy, MomentumStrategy, UsMomentumStrategy, RangeBreakStrategy, ScalpingStrategy, TailCatchStrategy, UpdowStrategy, ) from .strategies.base import BaseStrategy from .utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from .utils.logger import get_logger, msg_mm, msg_tg from .utils.strategy_ids import EXCLUDED_STRATEGY_IDS, canonical_strategy_id logger = get_logger("kis_trader.main") # kv_store — 당일 시작 총자산 (재시작해도 09:00 baseline 유지) _KV_START_ASSET_DATE = "kis_trader.start_day_asset.date" _KV_START_ASSET_VALUE = "kis_trader.start_day_asset.value" _LEGACY_STRATEGY_SQL = ", ".join(["%s"] * len(EXCLUDED_STRATEGY_IDS)) # HTS 저장 조건식 목록과 매핑 (seq:name — 기동 시 DB 비어 있으면 자동 채움) # 0:breakout 1:momentum 2:reversal 3:scalp 4:tail _DEFAULT_CONDITION_BY_STRATEGY: Dict[str, Tuple[str, str]] = { "SCALP": ("scalp", "3"), "SHORT": ("tail", "4"), "MOMENTUM": ("momentum", "1"), "BREAKOUT": ("breakout", "0"), "RANGE_BREAK": ("momentum", "1"), "UPDOW": ("reversal", "2"), "DBBAND": ("dbband", "5"), } # 키움 HTS 전용 조건식 — KIS NAME 과 다를 때만 별도 지정 (비면 CONDITION_{SID}_NAME 폴백) _DEFAULT_KIWOOM_CONDITION_BY_STRATEGY: Dict[str, Tuple[str, str]] = { "SCALP": ("scalp_re", ""), "SHORT": ("tail", ""), "MOMENTUM": ("momentum", ""), "BREAKOUT": ("breakout", ""), } _CONDITION_ENV_KEYS: Dict[str, Tuple[str, str]] = { "SCALP": ("CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ"), "SHORT": ("CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ"), "BREAKOUT": ("CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ"), "RANGE_BREAK": ("CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ"), "MOMENTUM": ("CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ"), "UPDOW": ("CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ"), "DBBAND": ("CONDITION_DBBAND_NAME", "CONDITION_DBBAND_SEQ"), } # 키움 전용 조건식 — ``CONDITION_{SID}_KIWOOM_NAME`` / ``CONDITION_{SID}_KIWOOM_SEQ`` # UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용 (seq 비면 CNSRLST 로 name→seq 자동 해결). _STARTUP_NOTIFY_STRATEGY_ORDER: Tuple[str, ...] = ( "SCALP", "SHORT", "MOMENTUM", "BREAKOUT", "RANGE_BREAK", "UPDOW", "DBBAND", ) def _mask_account_no(raw: str) -> str: s = str(raw or "").strip() if not s: return "-" if len(s) <= 5: return s[0] + "***" + s[-1] return s[:3] + "***" + s[-2:] class TradingOrchestrator: """전체 라이프사이클 관리 (공유 인프라 초기화 → 전략 쓰레드 기동 → 정리).""" # 전략별 UNIVERSE_SOURCE 기본값 (BaseStrategy 와 동기화) _DEFAULT_SOURCE = { "SCALP": "kiwoom_condition", "SHORT": "kiwoom_condition", "BREAKOUT": "kiwoom_condition", "RANGE_BREAK": "condition", "MOMENTUM": "kiwoom_condition", "UPDOW": "condition", "DBBAND": "condition", } _DEFAULT_RANK_SORT = { "SCALP": "volume", "SHORT": "volume", "BREAKOUT": "trading_value", "RANGE_BREAK": "trading_value", "MOMENTUM": "volume", "UPDOW": "volume", "DBBAND": "volume", } def __init__(self): self.db = get_db() # ── KIS 클라이언트 분리 정책 ──────────────────────────────────── # 한투 모의 서버는 시세/조회 API (분봉 inquire-time-itemchartprice, # 조건검색 psearch-*, 거래량순위 volume-rank 등) 다수가 미지원/HTTP 500. # 정책: **조회는 무조건 실키, 매매·계좌만 KIS_MOCK 따름.** # # self.client → 매매/계좌 (KIS_MOCK 따름) → OrderManager 주입 # 예: 주문 place, get_account_balance, 체결조회 # self.market_client → 시세/조회 (항상 실키) → 전략·WS·조건·랭킹 주입 # 예: inquire_price, get_minute_chart, condition, # volume_rank 등 GET 류 # 실키(KIS_APP_KEY_REAL/_SECRET_REAL) 미설정 시 market_client = self.client # 폴백 + 워닝. 그 경우 모의 도메인으로 시세 호출 가능 → 일부 500 발생. self.client = KISClient() # 거래용 (mock or real) self._market_data_client: KISClient | None = None # lazy 실키 self.market_client: KISClient = self._build_market_client() self.cash_ledger = AccountCashLedger(self.db) self.order_mgr = OrderManager( client=self.client, db=self.db, cash_ledger=self.cash_ledger, ) self.ws = WSManager(db=self.db, kis_client=self.market_client) # 해외(US) 실시간(지연)체결가 수신기 — 국내 WS와 분리된 별도 인스턴스(야간 가동). # permanent_subscriptions 테이블의 US 종목을 HDFSCNT0 로 구독. start()에서 기동. self.overseas_ws = None self.condition_mgr: ConditionSearchManager | None = None self.kiwoom_condition_mgr: KiwoomConditionSearchManager | None = None self.ls_condition_mgr: LsConditionSearchManager | None = None self._pending_kiwoom_condition_configs: List[dict] = [] self._pending_ls_condition_configs: List[dict] = [] self.ranking_mgr: VolumeRankManager | None = None # 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 전면 차단). # 지수 조회는 모의 도메인 미지원 가능성 → 실키 우선, 폴백 self.client. self.market_guard: MarketGuard | None = None # 키움 시세 WS (기동은 _start_ws_validator: 키 있으면. WS_PROVIDER 로 끄지 않음) self.kiwoom_ws = None self.ws_validator = None self.ls_ws = None self.ls_ws_validator = None self.strategies: List[BaseStrategy] = [] self._stop = False # ── [자산/리포트 상태] ───────────────────────────────────────── # - start_day_asset: 장 시작 시점 총자산(예수금+평가액). # 매수/매도 알림의 '당일손익', 장마감 리포트 계산 기준. # - total_deposit: 총 입금액 (env_config.TOTAL_DEPOSIT). 누적손익 기준. # - _asset_cache: get_account_balance() 캐시 (API 호출 폭주 방지) # - _closing_report_date / _final_report_date: 당일 리포트 중복 발송 가드 self.start_day_asset: float = 0.0 self.total_deposit: float = get_env_float("TOTAL_DEPOSIT", 0.0) or 0.0 self._asset_cache: Optional[Dict[str, Any]] = None self._asset_cache_ts: float = 0.0 self._asset_cache_ttl: float = float(get_env_int("ASSET_CACHE_TTL_SEC", 2)) self._closing_report_date: str = "" self._final_report_date: str = "" # 09:00 장 시작 리포트 중복 발송 가드 (당일 1회) self._market_open_report_date: str = "" # 15:36~ 장마감 고아복구 중복 실행 가드 (당일 1회) 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 마다 폭주하던 이슈 방지. self._start_asset_retry_at: float = 0.0 self._start_asset_backoff_sec: int = get_env_int( "START_ASSET_RETRY_BACKOFF_SEC", 300 ) # OrderManager 에 체결 알림용 '자산 요약 라인 생성기' 주입. # (None 이면 기존 간단 문구만 나감) try: self.order_mgr.asset_line_provider = self._asset_line_for_notify # type: ignore[attr-defined] except Exception: pass # 체결 알림에 '전략별 당일 손익' 줄을 넣기 위한 생성기 주입. try: self.order_mgr.strategy_pnl_provider = self._strategy_daily_pnl_for_notify # type: ignore[attr-defined] except Exception: pass try: if hasattr(self.ws, "get_orderbook"): self.order_mgr.orderbook_provider = self.ws.get_orderbook # type: ignore[attr-defined] if hasattr(self.ws, "get_price"): self.order_mgr.price_quote_provider = self.ws.get_price # type: ignore[attr-defined] if hasattr(self.ws, "get_tick_feed_label"): self.order_mgr.tick_feed_label_provider = self.ws.get_tick_feed_label # type: ignore[attr-defined] if hasattr(self.ws, "get_ob_feed_label"): self.order_mgr.ob_feed_label_provider = self.ws.get_ob_feed_label # type: ignore[attr-defined] except Exception: pass # ------------------------------------------------------------------ def _register_strategies(self) -> None: # 전략 BaseStrategy.client = 시세/조회 전용 (항상 실키 또는 폴백). # 매매는 self.order_mgr 경유라서 trade client (self.client) 가 별도로 들어감. shared = dict( db=self.db, client=self.market_client, ws=self.ws, 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 get_env_bool("STRATEGY_SCALP_ENABLED", True): self.strategies.append(ScalpingStrategy(**shared)) logger.info("✅ [전략 등록] SCALP (%s)", self._universe_tag("SCALP")) else: logger.info("⏸ [전략 비활성] SCALP") if get_env_bool("STRATEGY_SHORT_ENABLED", True): self.strategies.append(TailCatchStrategy(**shared)) logger.info("✅ [전략 등록] SHORT (Tail Catch) (%s)", self._universe_tag("SHORT")) else: logger.info("⏸ [전략 비활성] SHORT") if get_env_bool("STRATEGY_MOMENTUM_ENABLED", False): self.strategies.append(MomentumStrategy(**shared)) logger.info("✅ [전략 등록] MOMENTUM (%s)", self._universe_tag("MOMENTUM")) else: logger.info("⏸ [전략 비활성] MOMENTUM") # 해외 모멘텀 — 영구구독 US 고정 유니버스 · 기본 ON(시그널 dry-run) if get_env_bool("STRATEGY_US_MOMENTUM_ENABLED", True): self.strategies.append(UsMomentumStrategy(**shared)) logger.info("✅ [전략 등록] US_MOMENTUM (permanent US · dry-run 기본)") else: logger.info("⏸ [전략 비활성] US_MOMENTUM") if get_env_bool("STRATEGY_BREAKOUT_ENABLED", False): self.strategies.append(BreakoutStrategy(**shared)) logger.info("✅ [전략 등록] BREAKOUT (%s)", self._universe_tag("BREAKOUT")) else: logger.info("⏸ [전략 비활성] BREAKOUT") if get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False): self.strategies.append(RangeBreakStrategy(**shared)) logger.info("✅ [전략 등록] RANGE_BREAK (%s)", self._universe_tag("RANGE_BREAK")) else: logger.info("⏸ [전략 비활성] RANGE_BREAK") if get_env_bool("STRATEGY_UPDOW_ENABLED", False): self.strategies.append(UpdowStrategy(**shared)) logger.info("✅ [전략 등록] UPDOW (%s)", self._universe_tag("UPDOW")) else: logger.info("⏸ [전략 비활성] UPDOW") if get_env_bool("STRATEGY_DBBAND_ENABLED", False): self.strategies.append(DbBandStrategy(**shared)) logger.info("✅ [전략 등록] DBBAND (%s)", self._universe_tag("DBBAND")) else: logger.info("⏸ [전략 비활성] DBBAND") if get_env_bool("STRATEGY_DART_ENABLED", False): self.strategies.append(DartStrategy(**shared)) logger.info("✅ [전략 등록] DART (%s)", self._universe_tag("DART")) else: logger.info("⏸ [전략 비활성] DART") if not self.strategies: logger.error("전략이 하나도 활성화되지 않음 → 종료") sys.exit(1) self._setup_daily_profit_halt() def _setup_daily_profit_halt(self) -> None: """일일 익절 목표 가드 — 전략 쓰레드에 공통 주입.""" def _open_positions(sid: str) -> list: sid_u = str(sid or "").strip().upper() for strat in self.strategies: if str(getattr(strat, "strategy_id", "")).upper() != sid_u: continue from .engine.daily_profit_halt import build_open_risk_rows return build_open_risk_rows(sid_u, getattr(strat, "holdings", {}) or {}) return [] def _force_sell(sid: str, closes: list, reason: str) -> None: sid_u = str(sid or "").strip().upper() for strat in self.strategies: if str(getattr(strat, "strategy_id", "")).upper() != sid_u: continue for row in closes or []: code = str((row or {}).get("code") or "").strip() if not code: continue h = (getattr(strat, "holdings", {}) or {}).get(code) or {} qty = int((row or {}).get("qty") or h.get("qty") or 0) if qty <= 0: continue buy_price = float( (row or {}).get("buy_price") or h.get("buy_price") or 0 ) px = float( (row or {}).get("current_price") or h.get("max_price") or buy_price or 0 ) profit_pct = 0.0 if buy_price > 0 and px > 0: profit_pct = (px - buy_price) / buy_price * 100.0 try: strat._submit_sell({ "code": code, "name": (row or {}).get("name") or h.get("name") or code, "qty": qty, "current_price": px, "price": px, "buy_price": buy_price, "profit_pct": profit_pct, "reason": reason, }) except Exception as ex: logger.warning( "⚠️ [리스크버짓] %s %s 청산 실패: %s", sid_u, code, ex, ) return guard = DailyProfitHaltGuard( global_pnl_fn=self._bot_daily_realized_pnl, strategy_pnl_fn=self._bot_daily_realized_pnl_by_strategy, active_strategies_fn=lambda: [s.strategy_id for s in self.strategies], notify_fn=self._daily_profit_notify_mm, open_positions_fn=_open_positions, force_sell_fn=_force_sell, ) for strat in self.strategies: strat.daily_profit_halt = guard from .engine.daily_profit_halt import ( describe_profit_guard_startup, load_global_profit_target, load_strategy_profit_target, ) cfg = load_global_profit_target() logger.info(describe_profit_guard_startup(cfg, scope="마스터")) for strat in self.strategies: sid = str(getattr(strat, "strategy_id", "")).upper() scfg = load_strategy_profit_target(sid) if scfg.get("risk_budget_enabled"): logger.info( "🧯 [리스크버짓·%s] ON — 일일익절 hit 시 cushion < 보유최악손절합 이면 전량정리 " "(쿨다운 %ss)", sid, scfg.get("risk_budget_cooldown_sec"), ) def _daily_profit_notify_mm( self, body: str, strategy_id: Optional[str] = None, ) -> None: """일일 익절 달성 Mattermost 알림.""" ch_map = { "SCALP": ("KIS_SCALP_MM_CHANNEL", "scalping"), "SHORT": ("KIS_SHORT_MM_CHANNEL", "stock"), "MOMENTUM": ("KIS_MOMENTUM_MM_CHANNEL", "stock"), "US_MOMENTUM": ("KIS_US_MOMENTUM_MM_CHANNEL", "stock"), "BREAKOUT": ("KIS_BREAKOUT_MM_CHANNEL", "stock"), "RANGE_BREAK": ("KIS_RANGE_BREAK_MM_CHANNEL", "stock"), "UPDOW": ("KIS_UPDOW_MM_CHANNEL", "stock"), "DBBAND": ("KIS_DBBAND_MM_CHANNEL", "stock"), } if strategy_id: env_key, default = ch_map.get( strategy_id.upper(), ("KIS_SHORT_MM_CHANNEL", "stock"), ) ch = str(get_env_from_db(env_key, default) or default) else: ch = str( get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock") or "stock", ) msg_mm(body, channel_alias=ch, jitter=False) def _resolve_source(self, strategy_id: str) -> str: """{STRATEGY}_UNIVERSE_SOURCE env → ranking|condition|kiwoom_condition|ls_condition.""" from .utils.universe_source import resolve_universe_source default = self._DEFAULT_SOURCE.get(strategy_id, "ranking") return resolve_universe_source(strategy_id, default=default) def _universe_tag(self, strategy_id: str) -> str: """로그용 — 전략이 어떤 유니버스를 쓰는지 한눈에.""" src = self._resolve_source(strategy_id) if src == "ranking": mgr = self.ranking_mgr elif src == "kiwoom_condition": mgr = self.kiwoom_condition_mgr elif src == "ls_condition": mgr = self.ls_condition_mgr else: mgr = self.condition_mgr if mgr is None: return f"{src}(매니저없음→DB폴백)" try: cfgs = getattr(mgr, "_configs", []) for c in cfgs: if c.get("strategy_id") == strategy_id: if src == "ranking": return ( f"ranking · {c.get('sort')} x{c.get('limit')}" ) nm = c.get("name") or "" sq = c.get("seq") or "" if src == "kiwoom_condition": label = "kiwoom_cond" elif src == "ls_condition": label = "ls_cond" else: label = "condition" if nm and sq: return f"{label} · {nm}(seq={sq})" return f"{label} · {nm or sq or '?'}" except Exception: pass if src == "condition": nk, sk = _CONDITION_ENV_KEYS.get(strategy_id, ("", "")) nm = (get_env_from_db(nk, "") or "").strip() sq = (get_env_from_db(sk, "") or "").strip() if nm or sq: return f"condition · {nm or '?'}(seq={sq or '?'})" return f"{src}(미매핑→DB)" def _ensure_all_universe_condition(self) -> None: """전략별 UNIVERSE_SOURCE=condition + CONDITION_* 기본값을 config_* DB에 반영.""" try: snap = self.db.get_merged_env_snapshot() except Exception as e: logger.warning("유니버스 condition 기본값 스킵(DB): %s", e) return patch: Dict[str, str] = {} for sid, (dname, dseq) in _DEFAULT_CONDITION_BY_STRATEGY.items(): # ⚠️ 미설정/유효하지 않은 값일 때만 기본값 주입. # 사용자가 명시적으로 고른 소스(ranking/condition/kiwoom_condition/ls_condition)는 # 재시작해도 덮어쓰지 않는다 (런타임 스위치 유지). cur_src = (snap.get(f"{sid}_UNIVERSE_SOURCE") or "").strip().lower() if cur_src not in ("ranking", "condition", "kiwoom_condition", "ls_condition"): patch[f"{sid}_UNIVERSE_SOURCE"] = self._DEFAULT_SOURCE.get(sid, "condition") nk, sk = _CONDITION_ENV_KEYS.get(sid, ("", "")) if nk and not (snap.get(nk) or "").strip(): patch[nk] = dname if sk and not (snap.get(sk) or "").strip(): patch[sk] = str(dseq) kw_nk = f"CONDITION_{sid}_KIWOOM_NAME" kw_sk = f"CONDITION_{sid}_KIWOOM_SEQ" kd = _DEFAULT_KIWOOM_CONDITION_BY_STRATEGY.get(sid) if kd: kdname, kdseq = kd if not (snap.get(kw_nk) or "").strip() and kdname: patch[kw_nk] = kdname if not (snap.get(kw_sk) or "").strip() and kdseq: patch[kw_sk] = str(kdseq) if not patch: return merged = dict(snap) merged.update(patch) try: env_id = self.db.insert_env_snapshot(merged) logger.info( "📌 유니버스 condition 기본값 DB 반영 (env_id=%s): %s", env_id, ", ".join(sorted(patch.keys())), ) except Exception as e: logger.error("유니버스 condition DB 저장 실패: %s", e) def _build_startup_notify_body( self, startup_cash_line: str = "", startup_cash_ok: bool = False, ) -> str: """기동 알림 — 전략별 유니버스(ranking/condition) 줄바꿈.""" lines: List[str] = [ "🚀 kis_trader 통합 봇 시작", f"활성 전략: {', '.join(s.strategy_id for s in self.strategies)}", "", "【유니버스】", ] enabled = {s.strategy_id for s in self.strategies} for sid in _STARTUP_NOTIFY_STRATEGY_ORDER: if sid not in enabled: continue src = self._resolve_source(sid) 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 "⚠️" lines.append(f"{prefix} [기동 계좌] {startup_cash_line}") return "\n".join(lines) def _system_mm_channel(self) -> str: """장시작·장마감 리포트 단일 MM 채널 alias. 기본 'default'(mm_config.json → 키스명령봇 채널). DB env 로 변경 가능. """ return str( get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default") or "default", ).strip() or "default" def _lifecycle_mm_channels(self) -> List[str]: """시작/종료 알림 채널 목록 — 시스템 채널(키스명령봇) + stock 채널. stock 채널에서 시작/종료를 짝으로 봐야 재시작/종료 구분이 되므로, 시스템 채널과 KIS_LIFECYCLE_MM_CHANNEL(기본 'stock') 양쪽에 발송한다. (중복 alias 는 1회만) """ chans: List[str] = [] for ch in ( self._system_mm_channel(), str(get_env_from_db("KIS_LIFECYCLE_MM_CHANNEL", "stock") or "").strip(), ): if ch and ch not in chans: chans.append(ch) return chans def _send_startup_notify( self, startup_cash_line: str = "", startup_cash_ok: bool = False, ) -> None: """기동 알림 — 시스템 채널(키스명령봇) + stock 채널 + Telegram. 시작/종료는 stock 채널에도 보내 재시작/종료를 짝으로 확인 가능하게 한다. """ body = self._build_startup_notify_body(startup_cash_line, startup_cash_ok) for ch in self._lifecycle_mm_channels(): try: msg_mm(body, channel_alias=ch, jitter=False) except Exception as e: logger.debug("기동 MM(%s) 전송 실패: %s", ch, e) try: msg_tg(body, jitter=False) except Exception as e: logger.debug("기동 TG 전송 실패: %s", e) # ------------------------------------------------------------------ def start(self) -> None: logger.info("=" * 70) 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) # 해외(US) 실시간 WS 기동 (permanent_subscriptions 테이블의 US 종목) self._start_overseas_ws() # 유니버스 매니저 (ranking/condition) 기동 self._start_universe_managers() # 시장 급락 서킷브레이커 (ENABLED=true 일 때만 폴링·봉 저장·PANIC 판정) self._start_market_guard() if self.market_guard is not None: # 전략 매수 루프 전 1회: ws_candles MG* + kv PANIC 복원 후 즉시 재판정 self.market_guard.bootstrap_sync() # 시세 WS 마이그레이션 검증 / WS_SUBSCRIBE_KIS_MINIMAL 시 키움 WS 기동 self._start_ws_validator() self._start_ls_ws_validation() # 키움 조건검색은 시세 WS 와 **동일 세션** 공유 → 시세 WS 기동 후에만 등록 self._start_kiwoom_condition_manager() self._start_ls_condition_manager() self._wire_ws_split_feed_if_needed() # 예수금 캐시: 전략 기동 전 API 선동기화 (재시작 직후 stale kv 사용 방지) startup_cash_line = "" startup_cash_ok = False try: snap = self._fetch_asset_snapshot(force=True) cash = float(snap.get("cash", 0) or 0) dnca = float(snap.get("dnca", 0) or 0) d2 = float(snap.get("d2", 0) or 0) ord_psbl = float(snap.get("ord_psbl", 0) or 0) total_asset = float(snap.get("total_asset", 0) or 0) hold_count = int(snap.get("hold_count", 0) or 0) basis = str(snap.get("cash_basis", "dnca") or "dnca") if cash > 0: startup_cash_ok = True logger.info("💰 [예수금캐시] 기동 API 선동기화 완료: %s원", f"{cash:,.0f}") startup_cash_line = ( f"계좌[{('모의' if self.client.mock else '실전')}] " f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | " f"보유 {hold_count}종목 / 총자산 {total_asset:,.0f}원 | " f"cash_basis={basis} | " f"예수금 {dnca:,.0f} / D+2 {d2:,.0f} / 주문가능 {ord_psbl:,.0f} / 사용기준 {cash:,.0f}" ) else: logger.warning( "⚠️ [예수금캐시] 기동 API 응답 cash=0 → stale kv baseline 비활성" ) self.cash_ledger.invalidate_baseline("startup_cash_zero") startup_cash_line = ( f"계좌[{('모의' if self.client.mock else '실전')}] " f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | " "기동 예수금 동기화 실패(cash=0)" ) except Exception as e: logger.warning( "⚠️ [예수금캐시] 기동 API 선동기화 실패: %s → stale kv baseline 비활성", e ) self.cash_ledger.invalidate_baseline("startup_sync_failed") startup_cash_line = ( f"계좌[{('모의' if self.client.mock else '실전')}] " f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | " f"기동 예수금 동기화 예외: {e}" ) # 전략 쓰레드 기동 전: 실잔고 1회로 active_trades 맞춤 (매도 없음). # 고아 upsert · 유령 삭제 · 수량/평단 sync. EOD 배치와 동일 함수. # ORPHAN_RECONCILE_DATE 는 건드리지 않음 → 당일 Pre/Post EOD 는 그대로 1회 더 돈다. if get_env_bool("ORPHAN_RECONCILE_ON_STARTUP", True): try: self._run_orphan_reconcile(dt.now().strftime("%Y-%m-%d"), phase="startup") except Exception as e: logger.warning("🧩 [기동 잔고=DB 맞춤] 실패(스킵, 매매 계속): %s", e) # 전략 등록 후 쓰레드 시작 (각자 독립 쓰레드) self._register_strategies() # 해외 WS 는 등록 전에 기동됨 → 여기서 US_MOMENTUM 에 핸들 주입 self._attach_overseas_ws_to_strategies() for strat in self.strategies: strat.start() # threading.Thread.start() logger.info("▶ 쓰레드 기동: %s", strat.name) self._wire_short_holding_peak_provider() # 당일 시작 총자산 — kv 복원 또는 09:00 이후 최초 1회 기록 self._restore_start_day_asset() try: boot_snap = self._fetch_asset_snapshot() if self.start_day_asset <= 0 and float(boot_snap.get("total_asset") or 0) > 0: now_hm = dt.now() if now_hm.hour > 9 or (now_hm.hour == 9 and now_hm.minute >= 0): self._save_start_day_asset(float(boot_snap["total_asset"])) except Exception as e: logger.debug("start_day_asset 기동 설정 스킵: %s", e) # 알림 (시작) — stock MM + TG, 전략별 ranking/condition 줄바꿈 try: self._send_startup_notify(startup_cash_line, startup_cash_ok) except Exception: pass # 시그널 핸들러 (SIGINT/SIGTERM) signal.signal(signal.SIGINT, self._on_signal) try: signal.signal(signal.SIGTERM, self._on_signal) except Exception: pass # 메인 쓰레드는 heartbeat 만 찍으며 대기 self._heartbeat_loop() # ------------------------------------------------------------------ def _start_universe_managers(self) -> None: """ 전략별 UNIVERSE_SOURCE 에 따라 VolumeRankManager / ConditionSearchManager 기동. 두 매니저 모두 전략에 주입되고, 전략은 본인 소스 선택 값으로 조회함. """ self._ensure_all_universe_condition() strategy_switch = { "SCALP": get_env_bool("STRATEGY_SCALP_ENABLED", True), "SHORT": get_env_bool("STRATEGY_SHORT_ENABLED", True), "BREAKOUT": get_env_bool("STRATEGY_BREAKOUT_ENABLED", False), "RANGE_BREAK": get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False), "MOMENTUM": get_env_bool("STRATEGY_MOMENTUM_ENABLED", False), "US_MOMENTUM": get_env_bool("STRATEGY_US_MOMENTUM_ENABLED", True), "UPDOW": get_env_bool("STRATEGY_UPDOW_ENABLED", False), } ranking_configs: List[dict] = [] condition_configs: List[dict] = [] # 키움 조건검색(WS 실시간) 소스 — KIS 와 별개. KIWOOM_CONDITION_{SID}_NAME/SEQ. kiwoom_condition_configs: List[dict] = [] # LS 서버저장조건(AFR) — ls_condition 소스. 이력은 ls_candidates_history. ls_condition_configs: List[dict] = [] cond_env_keys = { "SCALP": ("CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ"), "SHORT": ("CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ"), "BREAKOUT": ("CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ"), "RANGE_BREAK": ("CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ"), "MOMENTUM": ("CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ"), "UPDOW": ("CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ"), } rank_env_keys = { "SCALP": ("RANK_SCALP_SORT", "RANK_SCALP_LIMIT"), "SHORT": ("RANK_SHORT_SORT", "RANK_SHORT_LIMIT"), "BREAKOUT": ("RANK_BREAKOUT_SORT", "RANK_BREAKOUT_LIMIT"), "RANGE_BREAK": ("RANK_RANGE_BREAK_SORT", "RANK_RANGE_BREAK_LIMIT"), "MOMENTUM": ("RANK_MOMENTUM_SORT", "RANK_MOMENTUM_LIMIT"), "UPDOW": ("RANK_UPDOW_SORT", "RANK_UPDOW_LIMIT"), } # ⚡ 런타임 스위치 지원: # 기존에는 시작 시점의 {SID}_UNIVERSE_SOURCE 값 기준으로 한쪽 매니저에만 등록했지만, # 운영 중 DB 값만 바꿔도 즉시 전환되도록 **설정이 존재하는 모든 소스에 미리 등록** 한다. # 실제 어느 쪽을 쓸지는 BaseStrategy._load_candidates() 가 매 틱 DB 를 재조회하여 결정. # (단, 조건식 이름/seq 자체 변경은 매니저 내부 _configs 정적 바인딩이라 여전히 재시작 필요) for sid, enabled in strategy_switch.items(): if not enabled: continue # 해외 모멘텀: 영구구독 고정 — ranking/condition/키움 매니저 미등록 if sid == "US_MOMENTUM": logger.info("📌 [US_MOMENTUM] 유니버스=permanent US (조건검색/랭킹 매니저 스킵)") continue src = self._resolve_source(sid) # 시작 시 '주 소스' (로그/기본값용) # ── ranking 쪽 설정 수집 (UNIVERSE_SOURCE=ranking 일 때만 KIS REST 폴링) ───── # kiwoom_condition / condition 소스 전략은 거래량순위 REST 폴링에 등록하지 않는다. sort_key, limit_key = rank_env_keys[sid] sort_default = self._DEFAULT_RANK_SORT.get(sid, "volume") sort_val = ( get_env_from_db(sort_key, sort_default) or sort_default ).strip().lower() limit_val = get_env_int(limit_key, 20) register_ranking = src == "ranking" if register_ranking: ranking_configs.append({ "strategy_id": sid, "sort": sort_val, "limit": limit_val, "market": "J", }) # ── condition 쪽 설정 수집 (UNIVERSE_SOURCE=condition 일 때만 KIS REST 폴링) ──── # CONDITION_{SID}_NAME 은 키움 조건식과 HTS 동일명 공유용으로도 쓰임. # kiwoom_condition / ranking 소스 전략은 KIS psearch REST 폴링에 등록하지 않는다. name_key, seq_key = cond_env_keys[sid] nm = (get_env_from_db(name_key, "") or "").strip() sq = (get_env_from_db(seq_key, "") or "").strip() has_cond = bool(nm or sq) register_kis_cond = has_cond and src == "condition" if register_kis_cond: condition_configs.append({ "strategy_id": sid, "name": nm or None, "seq": sq or None, }) elif src == "condition": logger.warning( "⚠️ [%s] UNIVERSE_SOURCE=condition 인데 " "CONDITION_%s_NAME/SEQ 가 비어있음 → ranking 으로 자동 폴백 동작. " "(조건식 세팅 후엔 봇 재시작 필요)", sid, sid, ) # ── 키움 조건: UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용 ───── # 이름 → CONDITION_{SID}_NAME 공통 (KIS/키움 HTS 동일명이면 필드 1개로 충분) # seq → CONDITION_{SID}_KIWOOM_SEQ (선택, 비면 CNSRLST 자동 해결) kw_nm = (get_env_from_db(f"CONDITION_{sid}_KIWOOM_NAME", "") or "").strip() or nm kw_sq = (get_env_from_db(f"CONDITION_{sid}_KIWOOM_SEQ", "") or "").strip() has_kw_cond = bool(kw_nm or kw_sq) if has_kw_cond: kiwoom_condition_configs.append({ "strategy_id": sid, "name": kw_nm or None, "seq": kw_sq or None, }) elif src == "kiwoom_condition": logger.warning( "⚠️ [%s] UNIVERSE_SOURCE=kiwoom_condition 인데 " "CONDITION_%s_NAME(또는 KIWOOM_SEQ) 가 비어있음 → 다른 소스로 폴백.", sid, sid, ) # ── LS 조건: 이름 있으면 등록 (런타임 ls_condition 전환용) ───── # CONDITION_{SID}_LS_NAME > KIWOOM_NAME > CONDITION_NAME > 기본동명 ls_nm = ( (get_env_from_db(f"CONDITION_{sid}_LS_NAME", "") or "").strip() or kw_nm or DEFAULT_SID_TO_LS_NAME.get(sid, "") ) if ls_nm: ls_condition_configs.append({ "strategy_id": sid, "name": ls_nm, "seq": None, }) elif src == "ls_condition": logger.warning( "⚠️ [%s] UNIVERSE_SOURCE=ls_condition 인데 " "조건명(CONDITION_%s_NAME/LS_NAME) 이 비어있음 → 후보 0.", sid, sid, ) logger.info( "🔀 [%s] 시작소스=%s (매니저등록: ranking=%s condition=%s kiwoom=%s ls=%s) " "— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환 " "(KIS ranking/condition REST 는 시작소스 일치 시만 · " "LS 매니저 최초 기동은 재시작 필요할 수 있음)", sid, src, ("✓" if register_ranking else "✗"), ("✓" if register_kis_cond else "✗"), ("✓" if has_kw_cond else "✗"), ("✓" if ls_nm else "✗"), sid, ) # ── ranking 매니저 기동 ──────────────────────────────── # ⚠️ KIS 거래량순위(volume-rank, FHPST01710000)도 **모의투자 미지원** (HTTP 500). # 조건검색과 같은 정책: KIS_MOCK 와 무관하게 실전 도메인으로 호출. if ranking_configs: mkt_client = self._get_market_data_client() if mkt_client is None: logger.warning( "⚠️ KIS_APP_KEY_REAL/KIS_APP_SECRET_REAL 미설정 → " "거래량순위는 모의 미지원이라 실키 필수. 랭킹 매니저 비활성 " "(%d개 전략 DB 폴백)", len(ranking_configs), ) else: try: self.ranking_mgr = VolumeRankManager( client=mkt_client, configs=ranking_configs, db=self.db, ) if not self.ranking_mgr.start(): self.ranking_mgr = None except Exception as e: logger.error("랭킹 매니저 기동 실패: %s", e) self.ranking_mgr = None else: logger.info("ℹ️ ranking 소스 쓰는 전략 없음 → 랭킹 매니저 비활성") # ── condition 매니저 기동 ────────────────────────────── # ⚠️ KIS 종목조건검색(psearch-title/psearch-result)은 **모의투자 미지원**. # 따라서 KIS_MOCK 값과 무관하게 **실전 키 전용 KISClient 를 별도 생성**해 # 조건검색 매니저에만 주입한다. (WSManager 가 시세를 실키로 받는 정책과 동일) # 거래용 self.client 는 그대로 KIS_MOCK 스위치를 따른다 → 모의 매매 안전성 유지. if condition_configs: user_id = (get_env_from_db("KIS_HTS_ID", "") or "").strip() mkt_client = self._get_market_data_client() if not user_id: logger.warning( "⚠️ KIS_HTS_ID 미설정 → 조건검색 매니저 비활성 " "(%d개 전략 DB 폴백)", len(condition_configs), ) elif mkt_client is None: logger.warning( "⚠️ KIS_APP_KEY_REAL/KIS_APP_SECRET_REAL 미설정 → " "조건검색은 모의 미지원이라 실키 필수. (%d개 전략 DB 폴백)", len(condition_configs), ) else: try: self.condition_mgr = ConditionSearchManager( client=mkt_client, user_id=user_id, configs=condition_configs, db=self.db, ) if not self.condition_mgr.start(): self.condition_mgr = None except Exception as e: logger.error("조건검색 매니저 기동 실패: %s", e) self.condition_mgr = None else: logger.info("ℹ️ condition 소스 쓰는 전략 없음 → 조건검색 매니저 비활성") # ── 키움 조건검색: 시세 WS 기동 후 _start_kiwoom_condition_manager() 에서 등록 ── self._pending_kiwoom_condition_configs = kiwoom_condition_configs if not kiwoom_condition_configs: logger.info("ℹ️ kiwoom_condition 소스 쓰는 전략 없음 → 키움 조건검색 매니저 비활성") # ── LS 조건검색: start() 에서 _start_ls_condition_manager() ── self._pending_ls_condition_configs = ls_condition_configs if not ls_condition_configs: logger.info("ℹ️ LS 조건명 없음 → LS 조건검색 매니저 비활성") # ------------------------------------------------------------------ def _start_market_guard(self) -> None: """ 시장 급락 서킷브레이커 매니저 기동. - ``MARKET_GUARD_ENABLED=false`` (기본값) 면 지수 API·봉 저장 없음(쓰레드는 대기만). → DB 에서 true 로 바꾸면 재시작 없이 감시 시작. - ``bootstrap_sync()`` 는 ``start()`` 직후·전략 기동 전 1회 (재시작 PANIC 구멍 방지). - 지수 조회 (inquire-index-price) 는 모의 도메인 미지원 가능성 → market_client (실키 우선) 를 사용. 실키 없으면 self.client 폴백. - PANIC 진입 시 BaseStrategy._scan_and_buy() 가 매수 차단. """ try: self.market_guard = MarketGuard(client=self.market_client, db=self.db) self.market_guard.start() except Exception as e: logger.error("MarketGuard 기동 실패 (전략은 가드 없이 동작): %s", e) self.market_guard = None # ------------------------------------------------------------------ def _start_ws_validator(self) -> None: """키움 WS 기동 + (선택) KIS↔키움 가격 검증기.""" from .utils.env import get_env_from_db minimal = get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False) 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() force_real = force_real_str in ("true", "1", "yes", "y") app_key = app_secret = "" is_mock = False try: r = self.db.get_auth_env() if force_real: app_key = (r.get("KIWOOM_APP_KEY_REAL") or "").strip() app_secret = (r.get("KIWOOM_APP_SECRET_REAL") or "").strip() if not app_key or not app_secret: app_key = (r.get("KIWOOM_APP_KEY") or "").strip() app_secret = (r.get("KIWOOM_APP_SECRET") or "").strip() is_mock = False logger.info("🔑 키움 WS 자격증명: 실키/실전 도메인 (KIWOOM_WS_FORCE_REAL=true)") else: from .ws.kis_ws import _get_kiwoom_creds app_key, app_secret, is_mock = _get_kiwoom_creds(self.db) logger.info( "🔑 키움 WS 자격증명: KIS_MOCK 자동 (mock=%s) — 디버그 모드", is_mock, ) except Exception as e: logger.warning("키움 자격증명 로드 실패 → 키움 WS 비활성: %s", e) return if not app_key or not app_secret: logger.warning( "키움 %s 키 미설정 → 키움 WS 비활성 (KIWOOM_APP_KEY%s 확인)", "실키" if force_real else "키", "_REAL" if force_real else "", ) return if self.kiwoom_ws is None: try: from .ws.kiwoom_ws import KiwoomWebSocketPriceCache self.kiwoom_ws = KiwoomWebSocketPriceCache( app_key, app_secret, is_mock=is_mock, ) if not self.kiwoom_ws.start(): 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 return kis_ws_handle = getattr(self.ws, "ws_cache", None) if need_validator and kis_ws_handle: try: from .network.ws_validator import WSPriceValidator self.ws_validator = WSPriceValidator( kis_ws=kis_ws_handle, kiwoom_ws=self.kiwoom_ws, db=self.db, sync_kiwoom_to_kis=not minimal, ) self.ws_validator.start() 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 elif need_validator and not kis_ws_handle: logger.warning("KIS WS 핸들 미발견 → Validator 비활성") def _start_ls_ws_validation(self) -> None: """LS 실키 WS — 검증·조건이력·AFR·ls_condition 틱. 매매 **주문** = KIS. ``ls_condition``: LS US3 틱 + 키움 갭/분봉 (``ls_feed`` = 틱만 LS). """ from .utils.env import get_env_from_db validation_on = get_env_bool("LS_WS_VALIDATION_ENABLED", False) history_on = get_env_bool("LS_CONDITION_HISTORY_ENABLED", False) follow_on = get_env_bool("LS_WS_FOLLOW_CONDITION_UNIVERSE", True) ls_ws_enabled = get_env_bool("LS_WS_ENABLED", False) trade_ls = False try: for sid in ("SCALP", "BREAKOUT", "SHORT", "MOMENTUM"): if self._resolve_source(sid) == "ls_condition": trade_ls = True break except Exception: trade_ls = False if ( not validation_on and not (history_on and follow_on) and not trade_ls and not ls_ws_enabled ): logger.info( "ℹ️ LS WS 미기동 (LS_WS_ENABLED=false, VALIDATION=false, " "HISTORY/FOLLOW 미충족, ls_condition 전략 없음)" ) return if ls_ws_enabled and not trade_ls and not validation_on: logger.info( "📡 LS WS 기동 — LS_WS_ENABLED=true " "(구독 spill 3차·영구구독 RAM)" ) if trade_ls: logger.info( "📡 LS WS 기동 — ls_condition AFR+틱 " "(갭보정·분봉은 키움)" ) try: r = self.db.get_auth_env() except Exception as e: logger.warning("LS 키 로드 실패 → LS WS 비활성: %s", e) return if not r: logger.warning("env_auth_config 없음 → LS WS 비활성") return 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 비활성 " "(LS_APP_KEY_REAL / LS_APP_SECRET_REAL)" ) return try: from .ws.ls_ws import LSWebSocketPriceCache also_hoga = get_env_bool("LS_WS_ALSO_HOGA", True) self.ls_ws = LSWebSocketPriceCache( app_key, app_secret, is_mock=is_mock, also_hoga=also_hoga, ) if not self.ls_ws.start(): logger.warning("LS WS start 실패") self.ls_ws = None return self._ls_perm_codes = set() self._ls_perm_codes_ts = 0.0 self._ls_perm_ttl = float(get_env_int("PERM_SUB_UI_REFRESH_SEC", 15)) def _on_tick(code: str, payload: dict) -> None: # 1) LS 전용 테이블 — 호가와 동일: 구독 중이면 적재 (영구만이 아님). # 봉·VI 는 아래 _ls_should_persist(영구) 유지. if get_env_bool("LS_WS_TICK_SAVE", False) and self._ls_is_subscribed(code): self.db.insert_ls_ws_tick( code=code, ts=payload.get("ts"), price=float(payload.get("price") or 0), volume=payload.get("volume"), tot_volume=payload.get("tot_volume"), 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) if tr is not None: try: px = float(payload.get("price") or 0) vol = int(float(payload.get("volume") or 0)) che = str(payload.get("chetime") or "") if px > 0: mirror = get_env_bool("LS_WS_TICK_MIRROR_WS_TICKS", False) tr.on_tick( code, px, vol, che, market="KR", source="ls", persist_db=mirror, ) except Exception: pass def _on_candle(code: str, candle: dict) -> None: if self._ls_should_persist(code): self.db.upsert_ls_ws_candle(code=code, candle=candle) def _on_orderbook(code: str, snap: dict) -> None: if self._ls_should_persist_orderbook(code): self.db.insert_ls_ws_orderbook(code=code, snap=snap, market="KR") def _on_vi(code: str, payload: dict) -> None: if not self._ls_should_persist(code): return self.db.insert_ls_ws_vi( code=code, ts=payload.get("ts"), event_time=str(payload.get("event_time") or ""), vi_gubun=str(payload.get("vi_gubun") or "0"), krx_vi_gubun=payload.get("krx_vi_gubun"), nxt_vi_gubun=payload.get("nxt_vi_gubun"), svi_recprice=payload.get("svi_recprice"), dvi_recprice=payload.get("dvi_recprice"), vi_trgprice=payload.get("vi_trgprice"), tr_cd=str(payload.get("tr_cd") or ""), exchname=str(payload.get("exchname") or ""), ) self.ls_ws.attach_tick_recorder(_on_tick) self.ls_ws.attach_candle_flusher(_on_candle) self.ls_ws.attach_orderbook_recorder(_on_orderbook) self.ls_ws.attach_vi_recorder(_on_vi) try: self.ls_ws.set_candle_save_filter(self._ls_should_persist) except Exception: pass 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 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): logger.info( "LS WS VI(UVI) 구독 ON → ls_ws_vi " "(save=%s stale_sec=%s watchdog_skip=%s)", get_env_bool("LS_WS_VI_SAVE", True), get_env_int("LS_WS_VI_STALE_SEC", 180), get_env_bool("LS_WS_WATCHDOG_SKIP_WHEN_VI", True), ) # 영구구독 KR 선구독 (마스터 OFF면 빈 목록) try: import permanent_subs as _ps rows = _ps.subscribe_codes(self.db, "KR") for row2 in rows or []: c = str(row2.get("code") or "").strip() if c: self.ls_ws.subscribe(c, owner="permanent") logger.info("LS WS 영구구독 KR 선등록 %d종목 (owner=permanent)", len(rows or [])) except Exception as e: logger.debug("LS 영구구독 선등록 실패: %s", e) if validation_on: from .network.ls_ws_validator import LSWSPriceValidator kis_ws_handle = getattr(self.ws, "ws_cache", None) self.ls_ws_validator = LSWSPriceValidator( ls_ws=self.ls_ws, db=self.db, kis_ws=kis_ws_handle, kiwoom_ws=self.kiwoom_ws, ) self.ls_ws_validator.start() logger.info( "🔬 [LS 시세 검증] 그림자 ON — ls_ws_ticks / ls_ws_candles / " "ls_ws_orderbook / ls_ws_vi / ws_price_validation_ls (매매 경로 미사용)" ) else: logger.info( "📡 [LS WS] 기동 — ticks/candles/hoga " "(ENABLED=%s VALIDATION=%s HISTORY=%s trade_ls=%s)", ls_ws_enabled, validation_on, history_on, trade_ls, ) try: if getattr(self, "ws", None) is not None: self.ws._reattach_all_price_listeners() except Exception: pass except Exception as e: logger.warning("LS WS 기동 실패: %s", e) self.ls_ws = None self.ls_ws_validator = None def _ls_should_persist(self, code: str) -> bool: """LS 봉·VI DB: 영구구독 enabled 코드만 (마스터와 무관, 행 기준).""" now = time.time() ttl = float(getattr(self, "_ls_perm_ttl", 15.0) or 15.0) if now - float(getattr(self, "_ls_perm_codes_ts", 0) or 0) >= ttl: try: import permanent_subs as ps self._ls_perm_codes = ps.enabled_code_set(self.db, "KR") except Exception: self._ls_perm_codes = set() self._ls_perm_codes_ts = now from permanent_subs import should_persist_ls return should_persist_ls(code, getattr(self, "_ls_perm_codes", set())) def _ls_is_subscribed(self, code: str) -> bool: """LS WS 구독 중인가 (영구∪폴백 유니버스). 틱/호가 DB 공통 게이트.""" c = str(code or "").strip() if not c: return False ls = getattr(self, "ls_ws", None) if ls is None: return False try: with ls._sub_lock: return c in ls._subscribed except Exception: return False def _ls_should_persist_orderbook(self, code: str) -> bool: """LS 호가 DB: SAVE ON 이고 구독 중. 봉/VI(영구)와 분리.""" if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True): return False return self._ls_is_subscribed(code) def _maybe_save_ls_orderbook_on_tick(self, code: str, payload: dict) -> None: """LS 체결 1건당 호가 RAM 스냅 1장 → ls_ws_orderbook (틱 동기). 스냅 없거나 만료면 저장 안 함 (OHLC·키움으로 메우지 않음 = 실매 필터와 동일). """ if not self._ls_should_persist_orderbook(code): return 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 # 틱 시각(chetime) = 키움 FID20 / 한투 chetime 과 같은 축. # payload ts 는 datetime.now() 라서 쓰면 recv 시계가 되어 옵투나 pick 이 어긋남. snap_time = "" 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:][:6] if len(snap_time) < 14: existing = str(body.get("snap_time") or "").strip() if len(existing) >= 14 and existing[:14].isdigit(): snap_time = existing[:14] 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) 와 단일 세션 공유. 키움은 동일 OAuth 토큰으로 WS 2접속 시 먼저 붙은 쪽에 Bye(1000)를 보내 끊는다. 조건검색 전용 소켓을 따로 열면 시세 WS 가 5초마다 끊기는 증상이 발생한다. """ configs = self._pending_kiwoom_condition_configs or [] if not configs: return kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip() kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip() kw_key = kw_key or (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip() kw_secret = kw_secret or (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip() if not (kw_key and kw_secret): logger.warning( "⚠️ KIWOOM_APP_KEY_REAL/SECRET_REAL 미설정 → 키움 조건검색 매니저 비활성 " "(%d개 전략 다른 소스 폴백)", len(configs), ) return shared = self.kiwoom_ws if shared is None: logger.warning( "⚠️ 키움 시세 WS 미기동 → 조건검색 단독 접속 시도 " "(키움 시세 WS 미기동 — LIVE_TICK/MINIMAL 과 별개, 키·기동 로그 확인)" ) try: self.kiwoom_condition_mgr = KiwoomConditionSearchManager( app_key=kw_key, app_secret=kw_secret, is_mock=False, configs=configs, db=self.db, shared_ws=shared, ) 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 와 무관. 기동 조건 (둘 중 하나): - ``LS_CONDITION_HISTORY_ENABLED=true`` → 이력만 (실매 유니버스 소스와 분리) - 또는 어떤 전략이 ``UNIVERSE_SOURCE=ls_condition`` """ configs = self._pending_ls_condition_configs or [] if not configs: return history_on = get_env_bool("LS_CONDITION_HISTORY_ENABLED", False) want_ls_trade = False for c in configs: sid = str(c.get("strategy_id") or "").upper() if sid and self._resolve_source(sid) == "ls_condition": want_ls_trade = True break if not history_on and not want_ls_trade: logger.info( "ℹ️ LS 조건 설정 %d건 있으나 HISTORY=false 이고 " "UNIVERSE_SOURCE=ls_condition 전략 없음 → 매니저 미기동", len(configs), ) return try: self.ls_condition_mgr = LsConditionSearchManager( configs=configs, db=self.db, user_id=(get_env_from_db("LS_USER_ID", "") or "").strip(), use_mock=False, ) # start() 중 t1859 스냅샷 emit 을 놓치지 않도록 listener 선등록 follow = get_env_bool("LS_WS_FOLLOW_CONDITION_UNIVERSE", True) if follow and self.ls_ws is not None: def _on_ls_univ(codes: set) -> None: try: self.ls_ws.sync_owner_codes("condition", codes) except Exception as e: logger.debug("LS WS condition sync: %s", e) self.ls_condition_mgr.set_universe_codes_listener(_on_ls_univ) logger.info( "📡 LS 조건 유니버스 → LS WS follow ON (owner=condition, plain6=%s)", get_env_bool("LS_WS_CONDITION_PLAIN6_ONLY", True), ) elif follow and self.ls_ws is None: logger.warning( "⚠️ LS_WS_FOLLOW_CONDITION_UNIVERSE=true 이나 LS WS 미기동 " "— VALIDATION 또는 HISTORY+FOLLOW 로 WS 를 켜세요" ) if history_on: logger.info( "📼 LS_CONDITION_HISTORY_ENABLED — ls_candidates_history 적재 " "(실매 UNIVERSE_SOURCE 와 분리). 단독 collect_ls_condition_history 중지 권고." ) if not self.ls_condition_mgr.start(): self.ls_condition_mgr = None return if follow and self.ls_ws is not None: self.ls_condition_mgr.emit_universe_union() except Exception as e: logger.error("LS 조건검색 매니저 기동 실패: %s", e) self.ls_condition_mgr = None def _wire_short_holding_peak_provider(self) -> None: """SHORT 보유 종목 max_price → ws_candles.holding_peak (백테·tail_engine 정합).""" ca = getattr(self.ws, "candle_agg", None) if ca is None or not hasattr(ca, "set_peak_provider"): return short = next( (s for s in self.strategies if getattr(s, "strategy_id", "") == "SHORT"), None, ) if short is None: return ca.set_peak_provider( lambda code: (short.holdings.get(code) or {}).get("max_price"), ) logger.info("📌 SHORT holding_peak → CandleAggregator (ws_candles 정합)") def _start_overseas_ws(self) -> None: """ 해외(US) 실시간(지연)체결가 WS(HDFSCNT0) 기동. - permanent_subscriptions 테이블에서 market_type='US' (enabled) 종목을 구독. - 국내 WS(WSManager.ws_cache)와 완전히 분리된 별도 인스턴스 → 야간(미국장)만 가동. - 실패해도 봇 본체엔 영향 없음(REST 폴백/조회로 동작). ※ OVERSEAS_WS_ENABLED=false (env/DB) 로 끌 수 있음. ※ HDFSCNT0 는 '해외 실시간시세' 미신청 시 15분 지연으로 수신됨. """ if not get_env_bool("OVERSEAS_WS_ENABLED", True): logger.info("ℹ️ OVERSEAS_WS_ENABLED=false → 해외 WS 비활성") return # 해외 실시간시세는 실전(real) 자격증명 전용 real_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip() real_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip() if not real_key or not real_secret: logger.warning("⚠️ KIS_APP_KEY_REAL/_SECRET_REAL 미설정 → 해외 WS 비활성") return try: import permanent_subs as ps us_rows = ps.subscribe_codes(self.db, "US") except Exception as e: logger.warning("해외 영구구독 목록 로드 실패 → 해외 WS 비활성: %s", e) return if not us_rows: logger.info("ℹ️ 해외(US) 영구구독 종목 없음 → 해외 WS 미기동") return try: from .ws.kis_ws_overseas import KISOverseasWebSocket self.overseas_ws = KISOverseasWebSocket(real_key, real_secret) for row in us_rows: self.overseas_ws.add_symbol( row.get("code", ""), row.get("exchange", "NASD"), row.get("symbol") or row.get("code", ""), ) # 국내 WS 와 동일한 CandleAggregator 에 해외 틱도 흘려 봉 정합 유지 if getattr(self.ws, "candle_agg", None): self.overseas_ws.attach_candle_aggregator(self.ws.candle_agg) if getattr(self.ws, "tick_recorder", None): self.overseas_ws.attach_tick_recorder(self.ws.tick_recorder) ok = self.overseas_ws.start() logger.info( "🌎 해외 WS active=%s (US %d종목: %s)", ok, len(us_rows), ", ".join(r.get("code", "") for r in us_rows), ) if ok: # NOTE: start() 순서상 전략 등록(_register_strategies)보다 먼저 호출됨. # 이 시점엔 US_MOMENTUM 이 아직 없을 수 있음 → 등록 직후 # _attach_overseas_ws_to_strategies() 로 다시 주입. self._attach_overseas_ws_to_strategies() else: self.overseas_ws = None except Exception as e: logger.error("해외 WS 기동 실패 (봇은 정상 동작): %s", e) self.overseas_ws = None def _attach_overseas_ws_to_strategies(self) -> None: """US_MOMENTUM 전략에 해외 WS 핸들 주입 (등록/재기동 후 필수).""" ows = getattr(self, "overseas_ws", None) if ows is None: return n = 0 for s in self.strategies: if getattr(s, "strategy_id", "") == "US_MOMENTUM": s.overseas_ws = ows n += 1 if n: logger.info("🌎 해외 WS → US_MOMENTUM 전략 %d개 주입", n) def _wire_ws_split_feed_if_needed(self) -> None: """키움 WS 주입. ``WS_SUBSCRIBE_KIS_MINIMAL`` 이면 후보=키움·한투=보유만(영구KR=LS).""" if not self.kiwoom_ws: return ca = getattr(self.ws, "candle_agg", None) if not ca: return try: self.kiwoom_ws.attach_candle_aggregator(ca) 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) 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) # 무조건 WSManager에 키움 WS 인스턴스를 주입 (폴백/중복구독용) self.ws.set_kiwoom_ws(self.kiwoom_ws) if get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False): self.ws.activate_split_feed(True) except Exception as e: logger.warning("WS 분리 시세 연결 실패: %s", e) def _build_market_client(self) -> KISClient: """ 시세/조회 전용 실전 KISClient 생성 (init 단계). 정책 (`__init__` 주석 참고): - KIS_MOCK 와 무관하게 **항상 실전 도메인** 으로 시세 조회. - 거래용 self.client 가 이미 실전(KIS_MOCK=false)이면 토큰·세션 공유 위해 그대로 재사용 → 폴백. - 실키(KIS_APP_KEY_REAL/_SECRET_REAL) 미설정이면 self.client 폴백 + 워닝 (이 경우 모의 도메인으로 시세 호출 → inquire-price/balance 일부 500 가능). """ # 거래 client 가 실전이면 그대로 재사용 if not self.client.mock: self._market_data_client = self.client return self.client real_app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip() real_app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip() if not real_app_key or not real_app_secret: logger.warning( "⚠️ KIS_APP_KEY_REAL/_SECRET_REAL 미설정 → 시세 client 도 모의로 폴백. " "조건검색·분봉 등 모의 미지원 API 가 HTTP 500 폭주할 수 있음." ) self._market_data_client = self.client return self.client try: mc = KISClient( mock=False, app_key=real_app_key, app_secret=real_app_secret, account_no=(get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or "").strip(), account_code=(get_env_from_db("KIS_ACCOUNT_CODE_REAL", "01") or "01").strip(), ) logger.info( "🔑 시세 전용 실전 KISClient 생성 (모의 미지원 API 대응 — 매매는 KIS_MOCK 따름)" ) self._market_data_client = mc return mc except Exception as e: logger.error( "시세 전용 실전 KISClient 생성 실패 → 거래용 client 폴백: %s", e ) self._market_data_client = self.client return self.client def _get_market_data_client(self) -> KISClient | None: """ condition/ranking 매니저 호환 헬퍼. 실키 모드 client 가 확보돼 있으면 반환, 아니면 None (= 매니저 비활성 신호). """ mc = self.market_client if mc is not None and not mc.mock: return mc return None def _heartbeat_loop(self) -> None: 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] 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: n = self.order_mgr.poll_pending_fills() if n: logger.info("🔄 미체결 재조회 처리 %d건", n) except Exception as e: logger.debug("poll_pending_fills 예외: %s", e) last_pending_fill = now # 장중 자산/리포트 tick (20초 주기) if now - last_daily_tick >= 20: try: self._daily_report_tick() except Exception as e: logger.debug("daily_report_tick 예외: %s", e) last_daily_tick = now time.sleep(1) # ------------------------------------------------------------------ # 자산 스냅샷 / 당일·누적 손익 / 장마감 리포트 # ------------------------------------------------------------------ def _fetch_asset_snapshot(self, force: bool = False) -> Dict[str, Any]: """ KIS `inquire-balance` 호출하여 예수금/D+2/총자산/보유종목수를 dict 로 반환. TTL 캐시(기본 2초)로 API 부하 절감. """ t = time.time() if ( not force and self._asset_cache is not None and (t - self._asset_cache_ts) < self._asset_cache_ttl ): return dict(self._asset_cache) def _parse_amt(v) -> float: try: return float(str(v or 0).replace(",", "").strip()) except Exception: return 0.0 balance = {} try: balance = self.client.get_account_balance() or {} except Exception as e: logger.debug("get_account_balance 예외: %s", e) out2_raw = balance.get("output2") or [] if isinstance(out2_raw, dict): out2 = out2_raw elif isinstance(out2_raw, list) and out2_raw: out2 = out2_raw[0] else: out2 = {} out1_raw = balance.get("output1") or [] if isinstance(out1_raw, dict): out1 = [out1_raw] elif isinstance(out1_raw, list): out1 = out1_raw else: out1 = [] dnca = _parse_amt(out2.get("dnca_tot_amt")) d2 = _parse_amt(out2.get("prvs_rcdl_excc_amt")) # 주식잔고(inquire-balance) output2 에는 주문가능현금 필드가 없음(공식 매핑 확인). # 주문가능 = 매수가능조회 inquire-psbl-order 의 ord_psbl_cash / nrcvb_buy_amt. ord_psbl = 0.0 try: psbl = self.client.inquire_psbl_order( str(get_env_from_db("PSBL_ORDER_REF_CODE", "005930") or "005930"), price=0, ord_dvsn="01", ) or {} for k in ( "ord_psbl_cash", "nrcvb_buy_amt", "max_buy_amt", ): v = _parse_amt(psbl.get(k)) if v > 0: ord_psbl = v break except Exception as _e: logger.debug("매수가능조회(ord_psbl) 실패: %s", _e) if ord_psbl <= 0: # 폴백: 잔고 output2 에 혹시 있으면 (실전 일부 상품) for k in ( "ord_psbl_cash", "ord_psbl_amt", "ord_psbl", "buy_psbl_cash", "buy_psbl_amt", ): v = _parse_amt(out2.get(k)) if v > 0: ord_psbl = v break # output2.tot_evlu_amt = 예수금+평가액 (없을 경우 output1 로 합산) tot_evlu = _parse_amt(out2.get("tot_evlu_amt")) if tot_evlu <= 0: holdings_value = 0.0 for it in out1: qty = _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY")) if qty <= 0: continue evlu = _parse_amt(it.get("evlu_amt") or it.get("EVLU_AMT")) if evlu > 0: holdings_value += evlu else: price = _parse_amt(it.get("prpr") or it.get("PRPR")) holdings_value += price * qty tot_evlu = (d2 if d2 > 0 else dnca) + holdings_value hold_count = 0 for it in out1: q = _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY")) if q > 0: hold_count += 1 # 주문 상한 기준 (증거금/미수 사용 제어) # - dnca : 예수금총액만 사용 (증거금 미사용 권장, 기본값) # - d2 : D+2 예수금 우선 # - ord_psbl: 브로커 주문가능금액 우선 # - min : dnca/d2/ord_psbl 중 양수 최솟값 (가장 보수적) cash_basis = str(get_env_from_db("ACCOUNT_CASH_BASIS", "dnca") or "dnca").strip().lower() if cash_basis == "d2": cash = d2 if d2 > 0 else dnca elif cash_basis == "ord_psbl": cash = ord_psbl if ord_psbl > 0 else dnca elif cash_basis == "min": cands = [x for x in (dnca, d2, ord_psbl) if x > 0] cash = min(cands) if cands else 0.0 else: # 기본: 증거금/미수 사용 방지 cash = dnca if dnca > 0 else (d2 if d2 > 0 else ord_psbl) snap = { "cash": cash, # 주문가능(= D+2 or 예수금총액) "d2": d2, # D+2 예수금 "dnca": dnca, # 예수금총액 "ord_psbl": ord_psbl, # 주문가능금액 (브로커 제공 시) "cash_basis": cash_basis, "total_asset": tot_evlu, "hold_count": hold_count, } self._asset_cache = snap self._asset_cache_ts = t try: self.cash_ledger.sync_from_snapshot(snap, source="api") except Exception as e: logger.debug("cash_ledger API 동기화 스킵: %s", e) return dict(snap) def _trade_db_raw(self): """TradeDBExt → raw TradeDB (kv_store 등).""" return getattr(self.db, "raw", self.db) def _is_forced_ghost_trade_row(self, row: Dict[str, Any]) -> bool: reason = str(row.get("sell_reason") or "") try: sell_price = float(row.get("sell_price") or 0) except Exception: sell_price = 0.0 is_forced = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason) return is_forced and sell_price <= 0 def _bot_daily_realized_pnl(self, day_yyyy_mm_dd: str) -> tuple[float, int]: """ 당일 봇 trade_history 실현손익 합 — 웹 「오늘 운영」·실거래 분석과 동일. Returns: (순실현손익 원, 청산 건수) """ day = (day_yyyy_mm_dd or "")[:10] if len(day) != 10: day = dt.now().strftime("%Y-%m-%d") day_start = f"{day} 00:00:00" day_end = f"{day} 23:59:59" ex = list(EXCLUDED_STRATEGY_IDS) sql = ( "SELECT realized_pnl, sell_reason, sell_price FROM trade_history " f"WHERE strategy NOT IN ({_LEGACY_STRATEGY_SQL}) " "AND sell_date >= %s AND sell_date <= %s" ) try: rows = self._trade_db_raw().conn.execute( sql, ex + [day_start, day_end], ).fetchall() except Exception as e: logger.debug("bot_daily_realized_pnl 조회 실패: %s", e) return 0.0, 0 total = 0.0 cnt = 0 for raw in rows or []: row = dict(raw) if self._is_forced_ghost_trade_row(row): continue total += float(row.get("realized_pnl") or 0) cnt += 1 return total, cnt def _bot_daily_realized_pnl_by_strategy( self, day_yyyy_mm_dd: str, strategy_id: str, ) -> tuple[float, int]: """ 당일 '특정 전략' 실현손익 합 — 체결 알림의 전략별 손익 줄용. _bot_daily_realized_pnl 과 동일 기준(유령거래 제외, trade_history)에 strategy 필터만 추가한다. Returns: (순실현손익 원, 청산 건수) """ day = (day_yyyy_mm_dd or "")[:10] if len(day) != 10: day = dt.now().strftime("%Y-%m-%d") day_start = f"{day} 00:00:00" day_end = f"{day} 23:59:59" # trade_history.strategy 는 canonical id 로 저장되므로 정규화 후 비교 sid = canonical_strategy_id(strategy_id) sql = ( "SELECT realized_pnl, sell_reason, sell_price FROM trade_history " "WHERE strategy = %s " "AND sell_date >= %s AND sell_date <= %s" ) try: rows = self._trade_db_raw().conn.execute( sql, [sid, day_start, day_end], ).fetchall() except Exception as e: logger.debug("bot_daily_realized_pnl_by_strategy 조회 실패: %s", e) return 0.0, 0 total = 0.0 cnt = 0 for raw in rows or []: row = dict(raw) if self._is_forced_ghost_trade_row(row): continue total += float(row.get("realized_pnl") or 0) cnt += 1 return total, cnt def _strategy_daily_pnl_for_notify(self, strategy_id: str): """ OrderManager 체결 알림 주입용 — 해당 전략의 '당일' (실현손익, 청산건수) 반환. 실패/예외 시 None 을 돌려 알림 줄을 생략한다. """ try: return self._bot_daily_realized_pnl_by_strategy( dt.now().strftime("%Y-%m-%d"), strategy_id, ) except Exception: return None def _restore_start_day_asset(self) -> bool: """kv 에 저장된 당일 시작 총자산 복원.""" today = dt.now().strftime("%Y-%m-%d") try: db = self._trade_db_raw() kv_date = (db.get_kv(_KV_START_ASSET_DATE) or "").strip() if kv_date != today: return False val_raw = (db.get_kv(_KV_START_ASSET_VALUE) or "").strip() if not val_raw: return False self.start_day_asset = float(val_raw) logger.info( "📌 [당일 시작 자산] kv 복원 %s원", f"{self.start_day_asset:,.0f}", ) return True except Exception as e: logger.debug("start_day_asset kv 복원 실패: %s", e) return False def _save_start_day_asset(self, value: float) -> None: """당일 시작 총자산 1회 기록 — 이미 있으면 덮어쓰지 않음 (재시작 baseline 유지).""" today = dt.now().strftime("%Y-%m-%d") try: db = self._trade_db_raw() kv_date = (db.get_kv(_KV_START_ASSET_DATE) or "").strip() if kv_date == today: val_raw = (db.get_kv(_KV_START_ASSET_VALUE) or "").strip() if val_raw: self.start_day_asset = float(val_raw) return if value <= 0: return self.start_day_asset = float(value) db.set_kv(_KV_START_ASSET_DATE, today) db.set_kv(_KV_START_ASSET_VALUE, str(int(round(self.start_day_asset)))) logger.info( "📌 [당일 시작 자산] %s원 기록 (kv)", f"{self.start_day_asset:,.0f}", ) except Exception as e: logger.debug("start_day_asset kv 저장 실패: %s", e) def _asset_line_for_notify(self, side: str = "", extra: Optional[Dict] = None) -> str: """ 체결 알림 하단 요약 (줄바꿈 정리): - 봇 실현손익: trade_history 당일 합 (웹과 동일, 수수료·세금 반영) - 계좌평가변동: 현재 총자산 − 당일 시작 총자산 (전체 계좌 MTM) """ try: self._restore_start_day_asset() snap = self._fetch_asset_snapshot() cash = snap.get("cash", 0.0) total = snap.get("total_asset", 0.0) hold = snap.get("hold_count", 0) today = dt.now().strftime("%Y-%m-%d") acct_day_pnl = ( (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0 ) acct_day_pct = ( (acct_day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0.0 ) bot_pnl, bot_cnt = self._bot_daily_realized_pnl(today) lines = [ "━━━━━━━━━━━━━━━━━━━━", "💼 **계좌**", f"예수금 {cash:,.0f}원", f"총자산 {total:,.0f}원 · 보유 {hold}종목", "", "📊 **손익 (당일)**", f"🤖 봇 실현 {bot_pnl:+,.0f}원 · 청산 {bot_cnt}건", f"📈 계좌평가 {acct_day_pnl:+,.0f}원 ({acct_day_pct:+.2f}%)", ] if self.total_deposit and self.total_deposit > 0: cum_pnl = total - self.total_deposit cum_pct = cum_pnl / self.total_deposit * 100 lines.extend([ "", "💰 **누적 (입금 대비)**", f"{cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)", ]) lines.append("━━━━━━━━━━━━━━━━━━━━") return "\n".join(lines) except Exception as e: logger.debug("asset line 생성 실패: %s", e) return "" def _daily_report_tick(self) -> None: """ 장중 주기적으로 호출되어: 1) 최초 1회 start_day_asset 기록 (09:00 이후 첫 조회) 2) 15:15 장마감 전 리포트 발송 (하루 1회) 3) 15:35 장마감 최종 리포트 발송 (하루 1회) """ now = dt.now() h, m = now.hour, now.minute today = now.strftime("%Y-%m-%d") today_ymd = now.strftime("%Y%m%d") # 날짜가 바뀌면 상태 리셋 if self._closing_report_date and self._closing_report_date != today: self.start_day_asset = 0.0 # 새 영업일 기준 재설정 self._start_asset_retry_at = 0.0 # 재시도 백오프도 같이 리셋 try: kv_date = (self._trade_db_raw().get_kv(_KV_START_ASSET_DATE) or "").strip() if kv_date and kv_date != today: self.start_day_asset = 0.0 except Exception: pass if self.start_day_asset <= 0: self._restore_start_day_asset() # env 에 TOTAL_DEPOSIT 가 있을 수도 있으므로 매일 한번 리로드 try: latest_td = get_env_float("TOTAL_DEPOSIT", 0.0) or 0.0 if latest_td > 0: self.total_deposit = latest_td except Exception: pass # 09:00 이후에만 start_day_asset 확보 (장 시작 전 예탁금 왜곡 방지). # 실패하면 START_ASSET_RETRY_BACKOFF_SEC (기본 300s) 동안 재시도 차단 → # 모의 서버 500 시 hb 20초마다 inquire-balance 폭주하던 이슈 방지. if self.start_day_asset <= 0 and (h > 9 or (h == 9 and m >= 0)): now_t = time.time() if now_t >= self._start_asset_retry_at: try: snap = self._fetch_asset_snapshot(force=True) if snap.get("total_asset", 0) > 0: self._save_start_day_asset(float(snap["total_asset"])) else: # snap 자체는 받았으나 total=0 (잔고 0 또는 모의 응답 깨짐) self._start_asset_retry_at = now_t + self._start_asset_backoff_sec logger.debug( "start_day_asset 조회 결과 total=0 → %ds 후 재시도", self._start_asset_backoff_sec, ) except Exception as e: self._start_asset_retry_at = now_t + self._start_asset_backoff_sec logger.debug( "start_day_asset 설정 실패: %s → %ds 후 재시도", 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): try: body = self._build_market_open_report(today_ymd) if body: self._send_daily_report("장 시작 알림", body) self._market_open_report_date = today except Exception as e: logger.error("장 시작 알림 실패: %s", e) # 15:15 장마감 전 (15:15~15:20 윈도우, 하루 1회) if (h == 15 and 15 <= m <= 20 and self._closing_report_date != today): try: body = self._build_closing_report(today_ymd) if body: self._send_daily_report("장마감 전 현황", body) self._closing_report_date = today except Exception as e: logger.error("장마감 전 리포트 실패: %s", e) # 15:35 장마감 최종 (15:34~15:45 윈도우, 하루 1회) if (h == 15 and 34 <= m <= 45 and self._final_report_date != today): try: body = self._build_final_report(today_ymd) if body: self._send_daily_report("장마감 최종 보고", body) self._final_report_date = today except Exception as e: logger.error("장마감 최종 리포트 실패: %s", e) # Pre-EOD 고아복구 — 가장 이른 EOD − N분 (하루 1회, EOD 청산 전) if self._orphan_pre_eod_reconcile_date != today: try: from .execution.orphan_reconcile import is_pre_eod_reconcile_window if is_pre_eod_reconcile_window(now): self._run_orphan_reconcile(today, phase="pre") self._orphan_pre_eod_reconcile_date = today except Exception as e: logger.error("Pre-EOD 고아복구 실패: %s", e) # 15:36~16:00 장마감 고아복구 (하루 1회 — 매매 없을 때 REST 잔고↔DB 대조) if self._orphan_reconcile_date != today: in_reconcile_window = (h == 15 and 36 <= m <= 59) or (h == 16 and m < 30) if in_reconcile_window: try: self._run_orphan_reconcile(today, phase="post") self._orphan_reconcile_date = today except Exception as e: logger.error("장마감 고아복구 실패: %s", e) def _run_orphan_reconcile(self, today: str, *, phase: str = "post") -> None: """봇 고아(active_trades 미기록) 1회 복구. phase=pre|post|startup.""" from .execution.orphan_reconcile import reconcile_orphan_positions result = reconcile_orphan_positions(self.order_mgr) n = int(result.get("reconciled_count") or 0) 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) 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": "Pre-EOD", "post": "장마감", "startup": "기동"}.get(phase, phase) if phase == "startup": logger.info("🧩 [%s 잔고=DB 맞춤] 복구·유령삭제 대상 없음", label) else: logger.debug("🧩 [%s 고아복구] 복구·유령삭제 대상 없음", label) return if result.get("error"): logger.warning("🧩 [고아복구] 중단: %s", result["error"]) return title = { "pre": "Pre-EOD 고아복구", "post": "장마감 고아복구", "startup": "기동 잔고=DB 맞춤", }.get(phase, "고아복구") lines = [ f"🧩 **[{title}]**", f"- 복구: {n}종목", f"- 계좌동기화: {s}종목", f"- 청산후스킵: {skip_sell}종목", f"- 청산후유령정리: {stale}종목", f"- 유령삭제: {g}종목", f"- 실패: {int(result.get('failed_count') or 0)}건", ] for it in (result.get("reconciled") or [])[:8]: lines.append( f" · {it.get('name')}({it.get('code')}) " f"[{it.get('strategy')}] {it.get('qty')}주" ) if n > 8: lines.append(f" …외 {n - 8}종목") for it in (result.get("skipped_after_sell") or [])[:5]: lines.append( f" ⏭스킵 {it.get('name')}({it.get('code')}) " f"{it.get('sell_reason')}@{it.get('sell_date')}" ) for it in (result.get("stale_purged") or [])[:5]: 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')}]" ) body = "\n".join(lines) try: from .utils.logger import msg_mm msg_mm(body, channel_alias=self._system_mm_channel(), jitter=False) except Exception as e: logger.debug("고아복구 MM 전송 실패: %s", e) logger.info( "🧩 [고아복구] 복구 %d · 계좌동기화 %d · 청산후스킵 %d · 청산후유령정리 %d · 유령삭제 %d → active_trades 반영", n, s, skip_sell, stale, g, ) def _build_market_open_report(self, today_ymd: str) -> str: """09:00 장 시작 알림 메시지 — 보유종목·예수금·주문가능금액 현황.""" snap = self._fetch_asset_snapshot(force=True) total = snap.get("total_asset", 0.0) cash = snap.get("cash", 0.0) d2 = snap.get("d2", 0.0) dnca = snap.get("dnca", 0.0) ord_psbl = snap.get("ord_psbl", 0.0) basis = str(snap.get("cash_basis", "dnca") or "dnca") hold = snap.get("hold_count", 0) active_sids = ", ".join(s.strategy_id for s in self.strategies) or "(없음)" lines = [ "🌅 **[장 시작 알림 - 09:00]**", "━━━━━━━━━━━━━━━━━━━━", f"- 🤖 활성 전략: {active_sids}", ( f"- 계좌: {('모의' if self.client.mock else '실전')} " f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}" ), f"- 현재 자산: {total:,.0f}원", f"- 보유 종목: {hold}개", ( f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | " f"주문가능금액: {ord_psbl:,.0f}원 | " f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원" ), "━━━━━━━━━━━━━━━━━━━━", "📈 오늘도 안전 매매! 손절 라인 준수.", ] return "\n".join(lines) def _build_closing_report(self, today_ymd: str) -> str: """15:15 장마감 전 현황 메시지 (기존 kis_short_ver3.py 포맷 계승).""" snap = self._fetch_asset_snapshot(force=True) total = snap.get("total_asset", 0.0) cash = snap.get("cash", 0.0) d2 = snap.get("d2", 0.0) dnca = snap.get("dnca", 0.0) ord_psbl = snap.get("ord_psbl", 0.0) basis = str(snap.get("cash_basis", "dnca") or "dnca") hold = snap.get("hold_count", 0) day_pnl = (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0 day_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0.0 bot_pnl, bot_cnt = self._bot_daily_realized_pnl( f"{today_ymd[:4]}-{today_ymd[4:6]}-{today_ymd[6:8]}" ) lines = [ "📈 **[장마감 전 현황 - 15:15]**", "", f"- 🤖 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)", f"- 📈 계좌평가: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)", f"- 현재 자산: {total:,.0f}원", f"- 보유 종목: {hold}개", ( f"- 계좌: {('모의' if self.client.mock else '실전')} " f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}" ), ( f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | " f"주문가능금액: {ord_psbl:,.0f}원 | " f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원" ), ] return "\n".join(lines) def _build_final_report(self, today_ymd: str) -> str: """15:35 장마감 최종 보고 메시지 (당일/누적 손익 + 매매건수).""" snap = self._fetch_asset_snapshot(force=True) total = snap.get("total_asset", 0.0) cash = snap.get("cash", 0.0) d2 = snap.get("d2", 0.0) dnca = snap.get("dnca", 0.0) ord_psbl = snap.get("ord_psbl", 0.0) basis = str(snap.get("cash_basis", "dnca") or "dnca") hold = snap.get("hold_count", 0) day_pnl = (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0 day_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0.0 bot_pnl, bot_cnt = self._bot_daily_realized_pnl( f"{today_ymd[:4]}-{today_ymd[4:6]}-{today_ymd[6:8]}" ) cum_pnl = (total - self.total_deposit) if self.total_deposit > 0 else 0.0 cum_pct = (cum_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 0.0 try: today_trades = self.db.get_trades_by_date(today_ymd) or [] except Exception: today_trades = [] trade_cnt = len(today_trades) lines = [ "🏁 **[장마감 최종 보고 - 15:35]**", "━━━━━━━━━━━━━━━━━━━━", "📅 **당일 손익**", f"- 🤖 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)", f"- 📈 계좌평가: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)", f"- 시작 총자산: {self.start_day_asset:,.0f}원", f"- 종료 총자산: {total:,.0f}원", "", "💰 **누적 (입금 대비)**", f"- 총 입금: {self.total_deposit:,.0f}원", f"- 현재 자산: {total:,.0f}원", f"- 누적 손익: {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)", "", "📊 **거래 현황**", f"- 오늘 매매: {trade_cnt}건", f"- 보유 종목: {hold}개", ( f"- 계좌: {('모의' if self.client.mock else '실전')} " f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}" ), ( f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | " f"주문가능금액: {ord_psbl:,.0f}원 | " f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원" ), "━━━━━━━━━━━━━━━━━━━━", ] return "\n".join(lines) def _send_daily_report(self, title: str, body: str) -> None: """장시작/장마감 리포트 — 시스템 알림 단일 채널(키스명령봇) + Telegram.""" sys_ch = self._system_mm_channel() try: msg_mm(body, channel_alias=sys_ch, jitter=False) except Exception as e: logger.debug("리포트 MM(%s) 전송 실패: %s", sys_ch, e) try: msg_tg(body, jitter=False) except Exception as e: logger.debug("리포트 TG 전송 실패: %s", e) logger.info("📢 %s 발송 완료", title) @staticmethod def _mgr_heartbeat_desc(mgr) -> str: if mgr is None: return "off" try: cfgs = getattr(mgr, "_configs", []) parts = [ f"{c['strategy_id']}={len(mgr.get_universe_for(c['strategy_id']))}" for c in cfgs ] return ",".join(parts) if parts else "0" except Exception: try: return str(len(mgr.get_universe())) except Exception: return "?" def _restart_dead(self) -> None: new_list = [] for s in self.strategies: if s.is_alive(): new_list.append(s) continue # 같은 타입으로 새 쓰레드 생성 (공유 인프라 주입) cls = type(s) ns = cls( db=self.db, client=self.market_client, ws=self.ws, 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 ns.start() new_list.append(ns) logger.info("🔁 전략 재기동: %s", ns.name) try: msg_mm(f"🔁 전략 재기동: {ns.strategy_id}") except Exception: pass self.strategies = new_list # ------------------------------------------------------------------ def _on_signal(self, *_): logger.info("🛑 종료 신호 수신 → 정리 시작") self._stop = True self.stop() sys.exit(0) def _build_shutdown_report(self) -> str: """종료 알림 메시지 — 보유종목·예수금·주문가능금액 + 당일 봇 실현손익. 종료는 장중/장후 어느 시점에나 발생할 수 있으므로 잔고 조회가 실패해도 최소 정보는 출력되도록 방어한다. (호출부에서 예외 시 단순 메시지로 폴백) """ now = dt.now() today_ymd = now.strftime("%Y%m%d") today_dash = now.strftime("%Y-%m-%d") lines = ["🛑 **kis_trader 종료**", "━━━━━━━━━━━━━━━━━━━━"] try: snap = self._fetch_asset_snapshot(force=True) total = snap.get("total_asset", 0.0) cash = snap.get("cash", 0.0) d2 = snap.get("d2", 0.0) dnca = snap.get("dnca", 0.0) ord_psbl = snap.get("ord_psbl", 0.0) basis = str(snap.get("cash_basis", "dnca") or "dnca") hold = snap.get("hold_count", 0) # 당일 봇 실현손익 (청산 완료분만) try: bot_pnl, bot_cnt = self._bot_daily_realized_pnl(today_dash) except Exception: bot_pnl, bot_cnt = 0.0, 0 lines += [ f"- 🕒 종료 시각: {now.strftime('%Y-%m-%d %H:%M:%S')}", f"- 🤖 당일 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)", ( f"- 계좌: {('모의' if self.client.mock else '실전')} " f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}" ), f"- 현재 자산: {total:,.0f}원", f"- 보유 종목: {hold}개", ( f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | " f"주문가능금액: {ord_psbl:,.0f}원 | " f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원" ), ] except Exception as e: logger.debug("종료 리포트 잔고 조회 실패: %s", e) lines.append(f"- 🕒 종료 시각: {now.strftime('%Y-%m-%d %H:%M:%S')}") lines.append("- ⚠️ 잔고 조회 실패 (네트워크/장후 등)") lines.append("━━━━━━━━━━━━━━━━━━━━") return "\n".join(lines) def stop(self) -> None: for s in self.strategies: s.stop_loop() for s in self.strategies: try: s.join(timeout=5) except Exception: pass try: if self.condition_mgr: self.condition_mgr.stop() except Exception: pass try: if self.kiwoom_condition_mgr: self.kiwoom_condition_mgr.stop() if self.ls_condition_mgr: self.ls_condition_mgr.stop() except Exception: pass try: if self.ranking_mgr: self.ranking_mgr.stop() except Exception: pass try: if self.ws_validator: self.ws_validator.stop() except Exception: pass try: if getattr(self, "ls_ws_validator", None): self.ls_ws_validator.stop() except Exception: pass try: if self.kiwoom_ws: self.kiwoom_ws.stop() except Exception: pass try: if getattr(self, "ls_ws", None): self.ls_ws.stop() except Exception: pass try: if self.overseas_ws: self.overseas_ws.stop() except Exception: pass try: self.ws.stop() except Exception: pass try: body = self._build_shutdown_report() for ch in self._lifecycle_mm_channels(): try: msg_mm(body, channel_alias=ch, jitter=False) except Exception as e: logger.debug("종료 MM(%s) 전송 실패: %s", ch, e) except Exception: # 종료 시 잔고 조회 실패 등 어떤 예외라도 최소 종료 메시지는 보낸다. for ch in self._lifecycle_mm_channels(): try: msg_mm("🛑 kis_trader 종료", channel_alias=ch, jitter=False) except Exception: pass def main() -> None: orch = TradingOrchestrator() try: orch.start() except KeyboardInterrupt: orch.stop() except Exception as e: logger.exception("오케스트레이터 치명적 예외: %s", e) orch.stop() sys.exit(1) if __name__ == "__main__": main()