From d1dc274f0e4ae852141b80c4d40d11dbc7acb322 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 19 Aug 2026 22:09:03 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=95=9C=ED=88=AC=20=ED=98=B8=EA=B0=80?= =?UTF-8?q?=202=ED=82=A4=20=EC=A0=84=EC=9A=A9,=20LS=20RAM=20=ED=95=A9?= =?UTF-8?q?=EC=A7=91=ED=95=A9,=20=EB=B6=84=EB=B4=89=20=EC=93=B0=EB=A0=88?= =?UTF-8?q?=EA=B8=B0=EB=A9=B4=20=EB=8B=A4=EC=9D=8C=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=ED=86=B5=EC=A7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 시세 41에 H0STASP0를 붙이는 폴백을 없앤다. MINIMAL과 무관하게 후보∪보유∪영구를 LS RAM에 붙인다. 그 분 틱이 없거나 봉끝 대비 늦으면 그 WS 봉을 버리고 2차·LS·REST 봉을 통째로 쓴다. 같은 파일에 직전 커밋 이후 쌓인 시세 폴백/ENV 키 정리도 포함된다. Co-authored-by: Cursor --- database.py | 316 +++++++++- docs/like_mcp.md/MODIFICATION_GUIDE.md | 43 +- docs/like_mcp.md/code_architecture.md | 17 +- kis_trader/backtest/bt_candle_source.py | 42 +- kis_trader/engine/feed_fallback.py | 327 ++++++++++ kis_trader/network/ws_manager.py | 726 +++++++++++++--------- kis_trader/web/live_config_schema.py | 165 ++++- kis_trader/ws/candle_series.py | 97 ++- kis_trader/ws/kis_ws.py | 408 +++++++++--- scripts/test_live_execution_validation.py | 464 +++++++++++++- 10 files changed, 2146 insertions(+), 459 deletions(-) create mode 100644 kis_trader/engine/feed_fallback.py diff --git a/database.py b/database.py index 077f879..46cff78 100644 --- a/database.py +++ b/database.py @@ -24,6 +24,30 @@ except ImportError: logger = logging.getLogger("TradeDB") +# 인증/계좌/ID 전용 키 — env_auth_config 컬럼 테이블로 분리 저장 +ENV_AUTH_KEYS = ( + "KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", + "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK", + "KIS_APP_KEY_OB_REAL", "KIS_APP_SECRET_OB_REAL", + "KIS_APP_KEY_OB_MOCK", "KIS_APP_SECRET_OB_MOCK", + "KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", + "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK", + "KIS_APP_KEY", "KIS_APP_SECRET", "KIS_ACCOUNT_NO", "KIS_ACCOUNT_CODE", + "KIS_HTS_ID", + "KIWOOM_APP_KEY_REAL", "KIWOOM_APP_SECRET_REAL", + "KIWOOM_APP_KEY_MOCK", "KIWOOM_APP_SECRET_MOCK", + "KIWOOM_APP_KEY", "KIWOOM_APP_SECRET", + "LS_APP_KEY_REAL", "LS_APP_SECRET_REAL", + "LS_APP_KEY_MOCK", "LS_APP_SECRET_MOCK", +) + +# 웹·알람이 아직 쓰는 구모듈은 legacy/ (remove/ 와 별도 — remove 삭제해도 유지). +try: + from kis_trader.utils.legacy_root import ensure_legacy_root as _ensure_legacy_root + _ensure_legacy_root() +except Exception: + pass + # ── MariaDB 기본 접속 정보 (환경변수로 재정의 가능) ───────────────── _DB_HOST = os.environ.get("DB_HOST", "192.168.0.141") _DB_PORT = int(os.environ.get("DB_PORT", "3306")) @@ -474,6 +498,8 @@ ENV_CONFIG_KEYS = ( "MANUAL_HOLD_CODES", # 장마감 후 봇 고아(active_trades 미기록) 자동 복구 ON/OFF "ORPHAN_RECONCILE_ENABLED", + # 봇 기동 직후 실잔고 1회 → active_trades 맞춤 (매도 없음, 기본 ON) + "ORPHAN_RECONCILE_ON_STARTUP", # Pre-EOD 고아복구 — 가장 이른 EOD − N분 (EOD 청산 전 1회) "ORPHAN_RECONCILE_PRE_EOD_ENABLED", "ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN", @@ -530,7 +556,7 @@ ENV_CONFIG_KEYS = ( "FX_USD_KRW_HTTP_ENABLED", # 백테 틱재생: 실매 후보=키움 정합. 분봉에 kiwoom 있으면 kis(과거 volume 오염) 제외 "WS_TICK_PREFER_KIWOOM", - # 후보/보유 이탈 후 틱 구독·저장 유지 (키움 한도 내, KIS 영구구독과 분리) + # 후보/보유 이탈 후 틱 구독·저장 유지 (키움 한도 내. 영구 KR은 LS, 한투 41과 분리) "WS_TICK_GRACE_SEC", "WS_TICK_GRACE_HEADROOM", # ws_orderbook / ws_program — TRIGGER 호가·프로그램 스냅샷 (백테 재현) @@ -547,8 +573,20 @@ ENV_CONFIG_KEYS = ( "WS_ORDERBOOK_COLLECT_ENABLED", # 진입 호가필터 RAM 나이. 0=마지막 호가로 검사(저장 TTL 과 분리) "WS_ORDERBOOK_FILTER_MAX_AGE_SEC", + # 매수/조회 get_price() 기본 나이. 0=마지막 RAM(체결 없어도 유지). 양수=그 초 넘으면 None. + "WS_PRICE_MAX_AGE_SEC", + # 실매·옵투나 읽기 폴백 나이(초). 메인 체결시각 vs 지금. 넘기면 그 벤더 실패→2차→3차. + "LIVE_FEED_FALLBACK_MAX_AGE_SEC", + "CANDLE_GARBAGE_FALLBACK", + # LS에 메인 틱/호가 유니버스(후보∪보유∪grace) 구독. 영구와 별개 owner. + "LS_FEED_FALLBACK_SUBSCRIBE", + # 후보 LS 갭 REST(t8412). 기본 OFF — 구독만. 영구 갭은 LS_GAP_FILL_ENABLED. + "LS_GAP_FILL_CANDIDATES", # 필터 ON + 호가 사진 한 장도 없으면 거절(기본 true). 만료 Fail-Open 과 별개. "WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY", + # 실매 매도: 마지막 WS 체결가 유지. 죽은선 감지 시에만 REST 1회(쿨다운). + "SELL_WS_STALE_REST_SEC", + "SELL_WS_STALE_REST_COOLDOWN_SEC", "WS_PROGRAM_COLLECT_ENABLED", "WS_TRIGGER_EVAL_DB_BATCH_SIZE", "WS_TRIGGER_EVAL_DB_FLUSH_SEC", @@ -752,6 +790,14 @@ ENV_CONFIG_KEYS = ( "SELL_LIMIT_CANCEL_FAIL_BALANCE_CONFIRM", "GHOST_PURGE_BLOCK_WHILE_PENDING_SELL", "GHOST_PURGE_RECORD_HISTORY", + # 유령정리 전 정정취소가능주문조회 (매도가능 0 ≠ 보유 0) + "INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST", + "PSBL_RVSECNCL_CACHE_TTL_SEC", + "PSBL_RVSECNCL_MAX_PAGES", + "PSBL_RVSECNCL_PAGE_GAP_SEC", + "PSBL_RVSECNCL_TR_ID", + "PSBL_RVSECNCL_INQR_DVSN_1", + "PSBL_RVSECNCL_INQR_DVSN_2", # 잔고 연속조회 최대 페이지 (1p=실전50/모의20종목) — 보유 많을 때 누락 방지 "BALANCE_MAX_PAGES", # WebSocket 실시간 가격 캐시 유효기간(초): 이 시간 이상 지나면 REST 재조회 @@ -998,7 +1044,7 @@ ENV_CONFIG_KEYS = ( # 전략 config_* RAM TTL — reload 매루프 SELECT 완화 (유니버스 분슬롯 금지와 별개 · 기본 1초) "STRATEGY_ENV_CACHE_TTL_SEC", # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강) - "TICK_SELL_ENABLED", "TICK_SELL_MIN_INTERVAL_MS", + "TICK_SELL_ENABLED", "TICK_SELL_MIN_INTERVAL_MS", "TICK_SELL_ASYNC_PLACE", "SHORT_TICK_SELL_ENABLED", "SCALP_TICK_SELL_ENABLED", "BREAKOUT_TICK_SELL_ENABLED", "MOMENTUM_TICK_SELL_ENABLED", "RANGE_BREAK_TICK_SELL_ENABLED", "UPDOW_TICK_SELL_ENABLED", @@ -1014,9 +1060,9 @@ ENV_CONFIG_KEYS = ( "MARKET_GUARD_POLL_SEC", # 지수 폴링 주기 초 (기본 30) "MARKET_GUARD_INDEX_CANDLE_KEEP_MIN", # 재시작 시 ws_candles MG* 1분봉 복원 개수 (기본 20) "MARKET_GUARD_PERSIST_STATE", # kv_store PANIC 영속 (기본 true) - # ── 전략별 후보 하드캡 (WS 구독 41 한도 보호 + 매수 체크 회전율 보장) ─ - # cond/ranking 매니저가 폭주해도 본 전략은 상위 N개만 처리. 0=무제한. - # 권장: 세 전략 합산 ≤ 30 (영구구독 + 보유 여유 11종목 확보). + # ── 전략별 후보 하드캡 (키움 WS 100 + 매수 체크 회전율) ─ + # cond/ranking 이 폭주해도 상위 N개만. 0=무제한. + # 키움 100 ≈ 후보합 + 보유 + grace. 영구 KR은 LS. 한투 41은 영구 전용이 아님(기본=후보도 구독). "SCALP_CAND_LIMIT", "SHORT_CAND_LIMIT", "BREAKOUT_CAND_LIMIT", "RANGE_BREAK_CAND_LIMIT", # ── 전략별 매매 시간대 (HHMM 정수, 930=09:30, 1500=15:00) ────────── @@ -1291,15 +1337,14 @@ ENV_CONFIG_KEYS = ( "DBBAND_DAILY_PROFIT_MODE", "DBBAND_DAILY_PROFIT_TRAIL_TIERS", "DBBAND_DAILY_PROFIT_TRAIL_DROP_PCT", "DBBAND_DAILY_PROFIT_TRAIL_ARM_KRW", "DBBAND_DAILY_PROFIT_TRAIL_ARM_PCT", "DBBAND_DAILY_PROFIT_RISK_BUDGET_ENABLED", "DBBAND_DAILY_PROFIT_RISK_BUDGET_COOLDOWN_SEC", - # ── 시세 WS 공급자 토글 (키움 시세 마이그레이션) ───────────────── - # 운영(매매 의사결정)에는 항상 KIS WS 만 사용. 키움 WS 는 검증 모드에서만 - # 백그라운드 동시 구독 → ws_price_validation 테이블에 가격 비교 기록. - # kis_only : 현행 (기본). 키움 WS 미기동. - # kis_with_validation : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음) - # kiwoom_only : 시세를 키움으로 전환 (검증 통과 후에만 사용) + # ── 시세 WS 스위치 (역할이 다름 — 섞지 말 것) ─────────────────── + # WS_PROVIDER: 구식 마이그레이션 토글(kis_only / kis_with_validation / kiwoom_*). + # 지금은 키움 WS 기동이 이 값에 묶이지 않음(_start_ws_validator: 키 있으면 기동). + # 교차검증 테이블은 LIVE_VALIDATOR_ENABLED. "WS_PROVIDER", - # true 시: KIS WS 는 PERMANENT_WS_CODES ∪ 보유 종목만 구독, 후보 종목은 키움 WS. - # 키움 WS 기동 필요(WS_PROVIDER=kis_with_validation 권장 또는 동시 true). + # MINIMAL = 한투 소켓에 *누구를 넣을지* (LIVE_TICK_PROVIDER 와 무관). + # true: 한투 WS=보유만, 후보=키움. false(기본): 한투도 후보∪보유(세션 41). + # 영구구독 KR은 LS. 메인을 한투로 바꾼다고 MINIMAL 명단이 바뀌지 않음. "WS_SUBSCRIBE_KIS_MINIMAL", # 구독 한도초과·실패·WS 미연결 시 즉시 벤더 spill (kis↔kiwoom→ls). 기본 true. 기존 LIVE_* 값 변경 없음. "WS_SUBSCRIBE_SPILL", @@ -1328,6 +1373,8 @@ ENV_CONFIG_KEYS = ( # true → 조건 구독 시 6자리 숫자 종목만 (우선주/특이코드 스킵) "LS_WS_CONDITION_PLAIN6_ONLY", "LS_WS_FORCE_REAL", + # 후보 틱 DB 기본 OFF. 영구 ETF 틱은 persist 게이트(영구만) 유지. + # INSERT 만 게이트. 호가 틱동기(LS_WS_ORDERBOOK_SAVE) 콜백을 같이 끄지 말 것. "LS_WS_TICK_SAVE", "LS_WS_CANDLE_SAVE", # true=LS 틱을 ws_ticks(source=ls) 에도 이중 저장 (용량↑). 기본 false — ls_ws_ticks 만 @@ -1344,7 +1391,7 @@ ENV_CONFIG_KEYS = ( "LS_WS_CONFIRMED_MAX", "LS_WS_VALIDATION_INTERVAL_SEC", "LS_WS_VALIDATION_DIFF_WARN_PCT", - "LS_WS_ALSO_HOGA", + "LS_WS_ALSO_HOGA", # UH1 구독. OFF면 호가 RAM·적재 없음. 코드기본 true. # LS 호가(UH1) → ls_ws_orderbook 적재 (프로그램 UPH 는 미사용·미구독) "LS_WS_ORDERBOOK_SAVE", # 종목당 DB INSERT 최소 간격(ms) — 호가 폭주 시 DB 부하 완화 (RAM 캐시는 매 틱) @@ -1392,6 +1439,13 @@ ENV_CONFIG_KEYS = ( "KIWOOM_WS_REG_CHUNK_SIZE", "KIWOOM_WS_REG_GAP_SEC", "KIWOOM_WS_REG_DEBOUNCE_SEC", + # REG refresh: 1=기존 구독 유지(기본). 0=LOGIN 일괄 첫 청크만 그룹 해지 후 재등록(테스트) + "KIWOOM_WS_REG_REFRESH", + # 키움 0B FID20(체결시간)이 recv 대비 이 값(초) 초과 stale 이면 wall-clock 으로 tick_time 보정 + "KIWOOM_TICK_TIME_MAX_LAG_SEC", + # 0=ws_ticks 전부 적재. 양수=FID20 vs 지금 초과 시 틱만 미저장. RAM skip 은 LIVE_FEED_FALLBACK. + "KIWOOM_TICK_LIVE_MAX_LAG_SEC", + "KIWOOM_TICK_STALE_LOG_GAP_SEC", # 키움 WS 재연결: 토큰거부(805004) 연타 후 영구 OFF 방지 (기본=쿨다운 리셋) "KIWOOM_WS_HARD_DISABLE_ON_MAX_RECONNECT", "KIWOOM_WS_MAX_RECONNECT_COOLDOWN_SEC", @@ -1615,6 +1669,50 @@ class TradeDB: _DB_HOST, _DB_PORT, _DB_NAME, ) + def _ensure_safe_index(self, table: str, idx_name: str, columns_sql: str) -> None: + """인덱스가 없을 때만 추가. 기존 인덱스는 삭제·변경하지 않음. + + MariaDB INPLACE + LOCK=NONE 우선 (읽기/쓰기 블로킹 없음). + 미지원이면 CREATE INDEX 폴백. Duplicate(1061) 는 무시. + """ + allowed = { + "target_candidates_history": "target_candidates_history", + } + tbl = allowed.get(str(table or "").strip()) + name = str(idx_name or "").strip() + cols = str(columns_sql or "").strip() + if not tbl or not name or not cols.startswith("("): + return + try: + rows = self.conn.execute(f"SHOW INDEX FROM {tbl}").fetchall() or [] + existing = set() + for r in rows: + kn = r["Key_name"] if isinstance(r, dict) else r[2] + if kn: + existing.add(str(kn)) + if name in existing: + return + except Exception as e: + logger.debug("SHOW INDEX %s 실패: %s", tbl, e) + return + try: + self.conn.execute( + f"ALTER TABLE {tbl} ADD INDEX {name} {cols}, " + "ALGORITHM=INPLACE, LOCK=NONE" + ) + logger.info("📌 %s.%s 인덱스 추가 (INPLACE LOCK=NONE)", tbl, name) + return + except Exception as e1: + try: + self.conn.execute(f"CREATE INDEX {name} ON {tbl} {cols}") + logger.info("📌 %s.%s 인덱스 추가", tbl, name) + except Exception as e2: + err = str(e2) + if "1061" not in err and "Duplicate" not in err: + logger.warning( + "idx %s 생성 실패: inplace=%s / create=%s", name, e1, e2 + ) + def _create_tables(self): """DB 테이블 생성 (없을 경우)""" with self.conn: @@ -1802,6 +1900,15 @@ class TradeDB: updated_at VARCHAR(30) NOT NULL ) CHARACTER SET utf8mb4 """) + # 8c. 인증/계좌/ID 전용 컬럼 저장소 (앱키/시크릿/ID 분리) + acols = ", ".join([f"`{k}` TEXT" for k in ENV_AUTH_KEYS]) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS env_auth_config ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at VARCHAR(30) NOT NULL, + {acols} + ) CHARACTER SET utf8mb4 + """.format(acols=acols)) # 9. AI 분석 기록 (Butler !클로드분석/!애미분석 시 프롬프트 요약·응답 저장 → 나중에 꺼내보기) self.conn.execute(""" @@ -2038,6 +2145,7 @@ class TradeDB: logger.debug(f"migrate trade_history ML columns: {e}") try: self._migrate_config_table_columns() + self._migrate_env_auth_from_legacy() self._migrate_strategy_keys_from_env_config() self._migrate_us_momentum_from_ext() self._migrate_short_time_to_tail_time() @@ -2286,6 +2394,12 @@ class TradeDB: logger.info("📌 stock_share_meta 테이블 확인/생성") except Exception as e: logger.warning(f"migrate stock_share_meta 실패: {e}") + # 오늘 운영 탭 당일 이력 집계용 — 기존 idx_slot 유지, 복합만 추가 + self._ensure_safe_index( + "target_candidates_history", + "idx_tch_slot_sid", + "(slot_key, strategy_id)", + ) def _migrate_ws_candles_source_channel(self) -> None: """ws_candles.channel 추가 + UNIQUE(code,tf,time,source,channel) + rest 라벨 정규화.""" @@ -4123,6 +4237,9 @@ class TradeDB: key = str(k).strip() if not key or classify_config_key(key) != "env_config": continue + # 앱키/시크릿/ID 는 env_auth_config 컬럼 전용 (ext 행으로 중복 저장하지 않음) + if key in ENV_AUTH_KEYS: + continue if key in cols: continue if v is None: @@ -4155,11 +4272,123 @@ class TradeDB: logger.info("📌 env_config_ext overflow 저장 %d키", saved) return saved + def _insert_env_auth_row(self, snapshot: Dict[str, Any], created_at: str) -> Optional[int]: + """env_auth_config 1행 INSERT (앱키/시크릿/ID/계좌 전용).""" + try: + key_list = ", ".join(f"`{k}`" for k in ENV_AUTH_KEYS) + placeholders = ", ".join(["%s"] * (1 + len(ENV_AUTH_KEYS))) + vals = [created_at] + [snapshot.get(k, None) for k in ENV_AUTH_KEYS] + cur = self.conn.execute( + f"INSERT INTO env_auth_config (created_at, {key_list}) VALUES ({placeholders})", + vals, + ) + return int(cur.lastrowid) if getattr(cur, "lastrowid", None) else None + except Exception as ex: + logger.debug("env_auth_config INSERT 실패: %s", ex) + return None + + def _read_latest_env_auth(self) -> Dict[str, str]: + """env_auth_config 최신 행의 비어있지 않은 값만.""" + out: Dict[str, str] = {} + try: + row = self.conn.execute( + "SELECT * FROM env_auth_config ORDER BY id DESC LIMIT 1", + ).fetchone() + if not row: + return out + keys = row.keys() if hasattr(row, "keys") else [] + for k in ENV_AUTH_KEYS: + if k in keys: + v = row[k] + if v is not None and str(v).strip() != "": + out[k] = str(v) + except Exception: + return out + return out + + def get_auth_env(self) -> Dict[str, str]: + """앱키/시크릿/ID/계좌 + KIS_MOCK. + + 인증키는 env_auth_config 최신 행 우선(get_merged_env_snapshot). + env_config 컬럼을 직접 SELECT 하지 말 것. + """ + merged = self.get_merged_env_snapshot() or {} + out: Dict[str, str] = {} + for k in ENV_AUTH_KEYS: + v = merged.get(k) + if v is not None and str(v).strip() != "": + out[k] = str(v) + mock = merged.get("KIS_MOCK") + if mock is not None and str(mock).strip() != "": + out["KIS_MOCK"] = str(mock) + return out + + def _read_env_auth_as_of(self, as_of: str) -> Dict[str, str]: + """env_auth_config as_of 이하 최신 행.""" + out: Dict[str, str] = {} + try: + row = self.conn.execute( + "SELECT * FROM env_auth_config WHERE created_at <= %s ORDER BY id DESC LIMIT 1", + (as_of,), + ).fetchone() + if not row: + return out + keys = row.keys() if hasattr(row, "keys") else [] + for k in ENV_AUTH_KEYS: + if k in keys: + v = row[k] + if v is not None and str(v).strip() != "": + out[k] = str(v) + except Exception: + return out + return out + + def _migrate_env_auth_from_legacy(self) -> None: + """ + env_auth_config 이 비어 있을 때, env_config 최신 행 + env_config_ext 키로 백필. + """ + try: + c = self.conn.execute("SELECT COUNT(*) c FROM env_auth_config").fetchone() + if c and int(c["c"]) > 0: + return + latest = self.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1", + ).fetchone() + snap: Dict[str, Any] = {} + if latest: + keys = latest.keys() if hasattr(latest, "keys") else [] + for k in ENV_AUTH_KEYS: + if k in keys: + v = latest[k] + if v is not None and str(v).strip() != "": + snap[k] = str(v) + ext_rows = self.conn.execute( + "SELECT env_key, env_value FROM env_config_ext", + ).fetchall() + for er in ext_rows or []: + ek = er["env_key"] if isinstance(er, dict) else er[0] + ev = er["env_value"] if isinstance(er, dict) else er[1] + if not ek: + continue + sk = str(ek).strip() + if sk in ENV_AUTH_KEYS and ev is not None and str(ev).strip() != "": + snap[sk] = str(ev) + if not snap: + return + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + rid = self._insert_env_auth_row(snap, now) + if rid: + logger.info("📌 env_auth_config 백필 완료 (id=%s, keys=%d)", rid, len(snap)) + except Exception as ex: + logger.debug("migrate env_auth_config: %s", ex) + def get_merged_env_snapshot(self) -> Dict[str, str]: """env_config + config_* 최신 행 병합 flat dict (get_env_from_db 호환).""" merged: Dict[str, str] = {} for tbl, keys in CONFIG_TABLE_KEYS.items(): merged.update(self._read_latest_config_table(tbl, keys)) + # 인증/계좌/ID 전용 컬럼 테이블 우선 + merged.update(self._read_latest_env_auth()) # 레거시 env_config(381컬럼) 에만 있는 값 — 신규 테이블에 없을 때 폴백 try: row = self.conn.execute( @@ -4234,6 +4463,7 @@ class TradeDB: return self.get_merged_env_snapshot() for tbl, keys in CONFIG_TABLE_KEYS.items(): merged.update(self._read_config_table_as_of(tbl, keys, as_of)) + merged.update(self._read_env_auth_as_of(as_of)) try: row = self.conn.execute( "SELECT * FROM env_config WHERE created_at <= %s ORDER BY id DESC LIMIT 1", @@ -4318,9 +4548,11 @@ class TradeDB: logger.error("config INSERT: 모든 테이블 저장 실패") return None ext_n = self._persist_env_config_overflow(snapshot, now) + auth_id = self._insert_env_auth_row(snapshot, now) logger.info( - "📌 env 스냅샷 저장 (env_config id=%s, 테이블 %d개, ext+%d)", + "📌 env 스냅샷 저장 (env_config id=%s, auth id=%s, 테이블 %d개, ext+%d)", primary_id, + auth_id, len(CONFIG_TABLE_KEYS), ext_n, ) @@ -4599,13 +4831,55 @@ class TradeDB: currency VARCHAR(8) NOT NULL DEFAULT 'KRW', source VARCHAR(16) NOT NULL DEFAULT 'kis', recv_ts VARCHAR(30) NOT NULL, + cntr_str DOUBLE DEFAULT NULL, + upper_limit_time VARCHAR(8) DEFAULT NULL, + tick_time_raw VARCHAR(64) DEFAULT NULL, KEY idx_ws_ticks_lookup (market, code, tick_time), KEY idx_ws_ticks_recv (recv_ts) ) ENGINE=InnoDB CHARACTER SET utf8mb4 """) + self._migrate_ws_ticks_extra_columns("ws_ticks") except Exception as e: logger.debug("ensure_ws_ticks_table: %s", e) + def _migrate_ws_ticks_extra_columns(self, table: str) -> None: + """키움 0B 부가 FID 컬럼 (구 DB ALTER).""" + table = (table or "ws_ticks").strip() + if table not in ("ws_ticks", "ws_ticks_us"): + return + try: + existing = { + r["Field"] + for r in self.conn.execute(f"SHOW COLUMNS FROM {table}").fetchall() + } + except Exception: + return + for col, ddl in ( + ("cntr_str", "DOUBLE DEFAULT NULL"), + ("upper_limit_time", "VARCHAR(8) DEFAULT NULL"), + ("tick_time_raw", "VARCHAR(64) DEFAULT NULL"), + ): + if col not in existing: + try: + self.conn.execute(f"ALTER TABLE {table} ADD COLUMN `{col}` {ddl}") + logger.info("✅ %s.%s 컬럼 추가", table, col) + except Exception as ex: + logger.warning("%s.%s ALTER 실패: %s", table, col, ex) + # tick_time_raw 20→64 확장 (KIS BSOP_DATE+체결시각·키움 FID20 원본) + try: + col_info = { + r["Field"]: r + for r in self.conn.execute(f"SHOW COLUMNS FROM {table}").fetchall() + } + ttr = col_info.get("tick_time_raw") + if ttr and "varchar(20)" in str(ttr.get("Type", "")).lower(): + self.conn.execute( + f"ALTER TABLE {table} MODIFY COLUMN tick_time_raw VARCHAR(64) DEFAULT NULL" + ) + logger.info("✅ %s.tick_time_raw VARCHAR(64) 확장", table) + except Exception as ex: + logger.debug("%s.tick_time_raw widen skip: %s", table, ex) + def ensure_ws_ticks_us_table(self) -> None: """해외 체결 틱 테이블 보장 (InnoDB, 국내 ws_ticks 와 분리).""" try: @@ -4623,10 +4897,14 @@ class TradeDB: currency VARCHAR(8) NOT NULL DEFAULT 'USD', source VARCHAR(16) NOT NULL DEFAULT 'kis_us', recv_ts VARCHAR(30) NOT NULL, + cntr_str DOUBLE DEFAULT NULL, + upper_limit_time VARCHAR(8) DEFAULT NULL, + tick_time_raw VARCHAR(64) DEFAULT NULL, KEY idx_ws_ticks_us_lookup (market, code, tick_time), KEY idx_ws_ticks_us_recv (recv_ts) ) ENGINE=InnoDB CHARACTER SET utf8mb4 """) + self._migrate_ws_ticks_extra_columns("ws_ticks_us") except Exception as e: logger.debug("ensure_ws_ticks_us_table: %s", e) @@ -4661,10 +4939,13 @@ class TradeDB: (it.get("currency") or default_currency)[:8], (it.get("source") or default_source)[:16], it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + float(it["cntr_str"]) if it.get("cntr_str") is not None else None, + (it.get("upper_limit_time") or None), + (it.get("tick_time_raw") or None), )) exec_conn = conn or self.conn try: - ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" placeholders = ", ".join([ph] * len(rows)) flat: List[Any] = [] for row in rows: @@ -4673,7 +4954,8 @@ class TradeDB: f""" INSERT INTO {table} (market, exchange, code, tick_time, price, volume, - tick_seq, session, currency, source, recv_ts) + tick_seq, session, currency, source, recv_ts, + cntr_str, upper_limit_time, tick_time_raw) VALUES {placeholders} """, tuple(flat), diff --git a/docs/like_mcp.md/MODIFICATION_GUIDE.md b/docs/like_mcp.md/MODIFICATION_GUIDE.md index f7e3838..1305bd7 100644 --- a/docs/like_mcp.md/MODIFICATION_GUIDE.md +++ b/docs/like_mcp.md/MODIFICATION_GUIDE.md @@ -17,6 +17,17 @@ **grep을 생략해도 되는 유일한 경우**: 주석/docstring 텍스트만 바꾸는 경우. +### 실매 코어 스모크 (중요 코드 수정 후 필수 1회) + +엔진/호가/WS(구독·해제·spill·갭보정·freeze)/env/스키마를 고친 뒤에는 완료 보고 전: + +```bash +python3 -u scripts/test_live_execution_validation.py +# 로그: logs/test_live_execution_validation_YYYYMMDD_HHMMSS.log +``` + +통과: `최종: 통과` + `👑 [최종 판정] 완결!`. 실패 시 고치고 1회만 재실행. 룰: `.cursor/rules/live-execution-validation.mdc` + --- ## 🔍 수정 유형별 필수 grep 목록 @@ -59,7 +70,23 @@ grep -r "E_MIN_CHG_PCT" /home/hoon/kis_bot/kis_trader/engine/momentum_env_keys.p 호가 필터 나이: `WS_ORDERBOOK_FILTER_MAX_AGE_SEC`(기본 0=마지막 RAM) ≠ 저장 TTL `WS_ORDERBOOK_TICK_MAX_AGE_SEC`. 후처리 TPE 진입 격자: `OPTUNA_OB_ENTRY_SPREAD_*` / `RATIO_*` / `ASK_MULT_*` (`optuna_common.ensure_optuna_gate_env_defaults`). -꼬리 TPE 진입모드는 탐색 축이 아님(웹 체크 → 스터디 고정/순차 2회). `*_SKIP_HTS_SCAN_DUPES` true 금지. +꼬리 TPE 진입모드는 탐색 축이 아님(웹 체크 → 스터디 고정/순차 2회). +돌파 TPE 손절모드(fixed/atr)도 탐색 축이 아님(웹 체크 → 스터디 고정/순차 2회). +align 스터디는 `limit_atr_mult` 제외. fixed 스터디는 `atr_sl_*` 제외. +`*_SKIP_HTS_SCAN_DUPES` true 금지. +유령정리: `INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST` · 잔고는 `hldg_qty`(보유)와 `ord_psbl_qty`(매도가능) 분리. 매도가능 0 ≠ ghost. + +시세·호가 읽기 폴백 (실매=옵투나 같은 읽기): +- `LIVE_FEED_FALLBACK_MAX_AGE_SEC`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지. +- 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 전부 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지. +- 한투 호가=`WS_ORDERBOOK_SAVE_KIS`: **2번째 앱키(`KIS_APP_KEY_OB_REAL`) 전용 세션만**. 키 없거나 start 실패 시 메인에 H0STASP0 붙이지 않음(시세 41 합산 금지). +- `LS_FEED_FALLBACK_SUBSCRIBE`(기본 true) → 후보∪보유∪영구∪grace 를 LS RAM 미러. **MINIMAL ON/OFF 무관** (`sync_targets` + split reconcile 둘 다). DB 틱 `should_persist_ls`(영구만) 이번 범위 밖. +- 키움 RAM/리스너/봉 skip = 읽기 나이. `ws_ticks` 적재는 기본 전부(`KIWOOM_TICK_LIVE_MAX_LAG_SEC=0`). 같이 내리지 말 것. +- 시세 REST 키 = `KIWOOM_WS_FORCE_REAL`(기본 true) 실키. `_get_kiwoom_creds` 가 `KIS_MOCK` 모의키로 가면 안 됨 (8001). +- `LS_GAP_FILL_CANDIDATES` 기본 OFF. +- `LS_WS_TICK_SAVE` 기본 false = `ls_ws_ticks` INSERT 만 OFF. `_tick_recorder` 를 같이 끄면 호가 틱동기도 0건. `LS_WS_ALSO_HOGA` 기본 true(UH1 구독). `LS_WS_ORDERBOOK_SAVE` 기본 true(구독 종목). 호가필터 `FILTER_MAX_AGE=0` 을 저장 TTL 과 다시 합치지 말 것. +- 매수 루프 전 종목 REST 금지. 매도 4차만 키움 `ka10007` + `SELL_WS_STALE_REST_COOLDOWN_SEC`. 한투 60초 캐시 금지. +- MM 체결 알림: `시세: kiwoom(1차) | 호가: ls(3차)`. **확인 후 수정해야 할 곳 체크리스트**: - [ ] `database.py` — CREATE TABLE에 컬럼 추가 @@ -204,6 +231,13 @@ grep -rn "trigger_e_confirm" /home/hoon/kis_bot --include="*.py" ## 🚨 이 프로젝트 특이 사항 (절대 무시 금지) +### 키움 0B catch-up / REG refresh +``` +KIWOOM_TICK_LIVE_MAX_LAG_SEC (기본 5): FID20 vs 수신시각 lag 초과 → RAM·봉·호가동기·ws_ticks 미반영. +KIWOOM_TICK_TIME_MAX_LAG_SEC (기본 120): 같은 FID20 가 이 초 이상일 때만 아침 동결 예외. +KIWOOM_WS_REG_REFRESH (기본 1): 0은 LOGIN 일괄 REG 첫 청크만. 장중 추가 REG는 항상 1. +``` + ### SKIP_HTS_SCAN_DUPES 규칙 ``` `*_SKIP_HTS_SCAN_DUPES` 는 사용자가 언급하기 전까지 false 유지. @@ -231,11 +265,16 @@ database.py의 CREATE TABLE 컬럼에 있는 키 → 해당 config_* 테이블 database.py의 CREATE TABLE에 없는 키 → env_config_ext (key-value 테이블) 새 env 키 추가 시 두 경로 중 어디에 저장될지 확인 필요. Optuna 후처리(실매 엔진 비영향): OPTUNA_POST_TOP_N / INCLUDE_MODE / INCLUDE_LIVE / - OB·WHIPSAW trial 수 · OPTUNA_OB_AXIS_TRIALS · 진입/익절/STOP 탐색 범위 — + OB·WHIPSAW trial 수 · OPTUNA_OB_COMBO_TRIALS_* · OPTUNA_OB_AXIS_TRIALS · 진입/익절/STOP 탐색 범위 — ensure_optuna_gate_env_defaults → env_config_ext. 모듈: kis_trader/backtest/optuna_postprocess_topn.py · optuna_orderbook_recommend.py · optuna_rerun_postprocess.py (구 JSON 「이 잡 후처리 재실행」) + 후처리 앵커: gated TopN + stable TopN(표/적용) + mode + live(참고). 합의·과적합 가점은 gated+mode만. + 웹 Optuna: 학습/gated/stable Top5 행에 안정점수(↑·만점없음·원) + 과적합%(↓·0~100). 1위 비교표·mode_combo 실측행은 표시 안 함. 진입 격자(2026-08-15): OPTUNA_OB_ENTRY_SPREAD 0.1~8 / RATIO 0.05~1.5 / ASK_MULT 1~80 / LOOKBACK 30분. + 8방 스택(2026-08-16/17): OPTUNA_OB_COMBO_TRIALS_SINGLE=150 / DOUBLE=200 / TRIPLE=250. + 적용=방 단위(build_combo_env_patch). 합의=8방 중 median PnL 최고 방(_consensus_from_anchors). + OPTUNA_OB_AXIS_TRIALS=500 은 축독립 잔여키(8방 미사용). 실매 필터 나이: WS_ORDERBOOK_FILTER_MAX_AGE_SEC(0) ≠ WS_ORDERBOOK_TICK_MAX_AGE_SEC(저장). 사진 한 장도 없으면: WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY(기본 true) → 필터 ON일 때 안 삼. ``` diff --git a/docs/like_mcp.md/code_architecture.md b/docs/like_mcp.md/code_architecture.md index f8ddc6e..2141aba 100644 --- a/docs/like_mcp.md/code_architecture.md +++ b/docs/like_mcp.md/code_architecture.md @@ -232,7 +232,8 @@ graph TD | `optuna_common.py` | 672 | 🔬 백테스트 | `database`, `optuna_briefing`, `env` | | `optuna_daily_trail_recommend.py` | 315 | 🔬 백테스트 | `param_search_apply_snapshot`, `env` | | `optuna_mode_combo.py` | 278 | 🔬 백테스트 | `optuna_postprocess_topn`, `optuna_tpe_common`, `env` | -| `optuna_postprocess_topn.py` | — | 🔬 백테스트 | `optuna_orderbook_recommend`, `optuna_whipsaw_recommend` | +| `optuna_post_progress.py` | — | 🔬 백테스트 | (후처리 진행 로그, 웹 바) | +| `optuna_postprocess_topn.py` | — | 🔬 백테스트 | `optuna_orderbook_recommend`, `optuna_whipsaw_recommend`, `optuna_post_progress` | | `optuna_rerun_postprocess.py` | — | 🔬 백테스트 | `optuna_postprocess_topn`, `optuna_momentum` | | `optuna_search_space.py` | 89 | 🔬 백테스트 | `param_search_breakout`, `param_search_momentum`, `param_search_scalping` +1 | | `optuna_tpe_common.py` | 218 | 🔬 백테스트 | | @@ -258,9 +259,11 @@ graph TD | `indicator_cache.py` | 412 | ⚙️ 엔진 | `ema_trend_filter`, `momentum_engine`, `env` | | `limit_entry_common.py` | 196 | ⚙️ 엔진 | | | `ls_feed_gate.py` | 20 | ⚙️ 엔진 | `env`, `ls_ws` | +| `feed_fallback.py` | — | ⚙️ 엔진 | `env` | | `mid_enroll_entry_gate.py` | 241 | ⚙️ 엔진 | `env` | | `orderbook_env.py` | 187 | ⚙️ 엔진 | `env` | | `orderbook_filter.py` | 276 | ⚙️ 엔진 | `env`, `orderbook_cache`, `trigger_eval_recorder` | +| `live_sell_price.py` | — | ⚙️ 엔진 | `env` | | `post_sell_candle_backfill.py` | 439 | ⚙️ 엔진 | `database`, `candle_rollup`, `env` +2 | | `program_filter.py` | 229 | ⚙️ 엔진 | `env`, `program_cache`, `trigger_eval_recorder` | | `strategy_eod.py` | 149 | ⚙️ 엔진 | | @@ -728,6 +731,8 @@ graph TD | `kis_trader/engine/momentum_tick_replay.py` | 620 | `kis_trader.backtest.shared_ticks`, `kis_trader.engine.momentum_engine`, `kis_trader.engine.whipsaw_filter`, `kis_trader.utils.env`, `kis_trader.utils.trade_time` | - | | `kis_trader/engine/orderbook_env.py` | 187 | `kis_trader.utils.env` | - | | `kis_trader/engine/orderbook_filter.py` | 276 | `kis_trader.utils.env`, `kis_trader.ws.orderbook_cache`, `kis_trader.ws.trigger_eval_recorder` | `orderbook_env`, `trigger_eval_collect` | +| `kis_trader/engine/live_sell_price.py` | — | `kis_trader.utils.env` | `base` 매도 현재가 | +| `kis_trader/engine/feed_fallback.py` | — | `kis_trader.utils.env` | 실매·옵투나 읽기 2초 폴백 | | `kis_trader/engine/post_sell_candle_backfill.py` | 439 | `database`, `kis_trader.engine.candle_rollup`, `kis_trader.utils.env`, `kis_trader.utils.logger`, `kis_trader.ws.kis_ws` | - | | `kis_trader/engine/program_filter.py` | 229 | `kis_trader.utils.env`, `kis_trader.ws.program_cache`, `kis_trader.ws.trigger_eval_recorder` | `trigger_eval_collect` | | `kis_trader/engine/range_break_engine.py` | 460 | `database`, `kis_trader.backtest.range_break_portfolio_backtest`, `kis_trader.engine.range_break_env_keys`, `kis_trader.strategies.breakout`, `kis_trader.utils.trade_time` | - | @@ -1027,7 +1032,17 @@ graph TD > AST 재생성 시 이 섹션 유지. - 판정: `kis_trader/engine/orderbook_filter.py` `_fetch_snapshot` — `WS_ORDERBOOK_FILTER_MAX_AGE_SEC`(기본 0). 사진 없음+필터 ON → `WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY`(기본 true). +- 실매 매수·매도 현재가: `WSManager.get_price()` 생략=읽기 폴백 나이(`LIVE_FEED_FALLBACK_MAX_AGE_SEC` 기본 3초)로 메인→2차→3차. 한투 호가는 `kis_ws_ob`(2키)만 — 메인 `H0STASP0` 폴백 금지. 분봉 pick: `CANDLE_GARBAGE_FALLBACK` + `dedupe_by_read_pairs`(메인 WS→2차→LS→REST→rollup). `max_age_sec=None` 만 마지막 RAM(`get_price_last`). 매도 4차는 `live_sell_price.py` 키움 ka10007 + 쿨다운(시세 실키, `KIS_MOCK` 모의키 금지). 한투 `inquire_price` 60초 캐시 금지. EOD는 마지막 WS→REST→매수가 폴백. 호가 `get_orderbook_snapshot` 도 같은 폴백 체인 — `FILTER_MAX_AGE=0` 과 저장 TTL 을 합쳐 만료=`None`=필터 ON 통과 구멍을 만들지 말 것. - 저장: `trigger_snapshot_recorder` — `WS_ORDERBOOK_TICK_MAX_AGE_SEC`(기본 30). **공유 금지.** - 캐시: `orderbook_cache.get(..., max_age_sec)` — `<=0` 이면 나이 무시. - 후처리 격자: `optuna_common.ensure_optuna_gate_env_defaults` `OPTUNA_OB_ENTRY_*` / `ASK_MULT_*`. - 구 JSON 후처리만: `optuna_rerun_postprocess.py`. + +## 수동 노트: 루트 레거시 파일 (2026-08-17) + +> AST 재생성 시 이 섹션 유지. + +실매 `kis_trader.main` 이 안 쓰는 파일은 `remove/` (**나중에 통째 삭제**). +웹·알람이 **아직 쓰는** `holding_bot` / `news_analyzer` / `kis_long_ver*` 는 `legacy/` (`ensure_legacy_root`). +`remove` 를 지워도 `legacy/` 는 남긴다. +상세 `legacy/README.md`, `remove/README.md`. diff --git a/kis_trader/backtest/bt_candle_source.py b/kis_trader/backtest/bt_candle_source.py index c0c2f61..7e5067f 100644 --- a/kis_trader/backtest/bt_candle_source.py +++ b/kis_trader/backtest/bt_candle_source.py @@ -75,6 +75,28 @@ def _pairs_in_sql(pairs: Sequence[ReadPair]) -> Tuple[str, List[str]]: return " AND (" + " OR ".join(parts) + ")", params +def _ticks_for_bar_garbage(db, code: str, start_key: str, end_key: str) -> Optional[List[Dict[str, Any]]]: + from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled + + if not candle_garbage_fallback_enabled(): + return None + sk = str(start_key or "").strip() + ek = str(end_key or "").strip() + if len(sk) == 12: + sk = sk + "00" + if len(ek) == 12: + ek = ek + "59" + try: + rows = db.conn.execute( + "SELECT tick_time, source, tick_time_raw FROM ws_ticks " + "WHERE code=%s AND tick_time >= %s AND tick_time <= %s", + (str(code).strip(), sk, ek), + ).fetchall() + return [dict(r) for r in (rows or [])] + except Exception: + return None + + def list_ws_candle_codes( db, timeframe: int, @@ -147,7 +169,11 @@ def fetch_ws_candles_for_code( + " ORDER BY candle_time ASC", [int(timeframe), code, start_key, end_key, *src_params], ).fetchall() - return dedupe_by_read_pairs([dict(r) for r in rows], pairs) + ticks = _ticks_for_bar_garbage(db, str(code), start_key, end_key) + return dedupe_by_read_pairs( + [dict(r) for r in rows], pairs, ticks=ticks, tf_min=int(timeframe), + missing_policy="hole", live_cover=False, + ) def fetch_ws_candles_warmup_before( @@ -180,5 +206,17 @@ def fetch_ws_candles_warmup_before( + " ORDER BY candle_time DESC LIMIT %s", [int(timeframe), code, before_candle_time, *src_params, fetch_limit], ).fetchall() - bars = dedupe_by_read_pairs([dict(r) for r in reversed(rows)], pairs) + raw_rows = [dict(r) for r in reversed(rows)] + t0 = str(raw_rows[0].get("candle_time") or "")[:12] if raw_rows else "" + ticks = ( + _ticks_for_bar_garbage(db, str(code), t0, str(before_candle_time)) + if t0 else None + ) + bars = dedupe_by_read_pairs( + raw_rows, pairs, + ticks=ticks, + tf_min=int(timeframe), + missing_policy="hole", + live_cover=False, + ) return bars[-limit:] if len(bars) > limit else bars diff --git a/kis_trader/engine/feed_fallback.py b/kis_trader/engine/feed_fallback.py new file mode 100644 index 0000000..7766dcb --- /dev/null +++ b/kis_trader/engine/feed_fallback.py @@ -0,0 +1,327 @@ +""" +kis_trader/engine/feed_fallback.py — 실매·옵투나 공통 읽기 나이 헬퍼 +====================================================================== +메인이 지금보다 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초보다 오래면 그 벤더는 없는 것과 같음. +2차(보조 WS) → 3차(LS) → (실매 매도만) 4차 키움 REST. +적재(공책) 임계와 읽기 나이는 분리한다. OHLC 로 틱을 메우지 않음. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db + + +def packet_lag_seconds(pkt_raw: str, *, now_dt: Optional[datetime] = None) -> Optional[float]: + """체결시각 문자열 vs now_dt(기본=서버 지금). 증권사끼리 비교 아님. + + YYYYMMDDHHMMSS 또는 HHMMSS. 파싱 실패면 None(나이를 모름 → 읽기에서 버리지 않음). + """ + tt = str(pkt_raw or "").strip() + if not tt: + return None + wall = now_dt if now_dt is not None else datetime.now() + pkt_dt = None + try: + if len(tt) >= 14 and tt[:14].isdigit(): + pkt_dt = datetime.strptime(tt[:14], "%Y%m%d%H%M%S") + elif len(tt) >= 6 and tt[-6:].isdigit(): + pkt_dt = datetime.strptime( + wall.strftime("%Y%m%d") + tt[-6:], "%Y%m%d%H%M%S", + ) + except Exception: + return None + if pkt_dt is None: + return None + return float((wall - pkt_dt).total_seconds()) + + +def is_feed_read_stale( + lag_sec: Optional[float], max_age_sec: Optional[float] = None, +) -> bool: + """읽기 나이 초과. lag/나이를 모르면 False(버리지 않음).""" + age = live_feed_fallback_max_age_sec() if max_age_sec is None else float(max_age_sec) + if age <= 0 or lag_sec is None: + return False + try: + return float(lag_sec) > age + except (TypeError, ValueError): + return False + + +def live_feed_fallback_max_age_sec() -> float: + """읽기 유효 나이(초). 기본 3. 0 이하면 나이로 벤더를 버리지 않음(레거시).""" + try: + v = float(get_env_float("LIVE_FEED_FALLBACK_MAX_AGE_SEC", 3.0) or 3.0) + except (TypeError, ValueError): + v = 3.0 + return v + + +def live_tick_primary() -> str: + p = str(get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower() + return p if p in ("kis", "kiwoom") else "kiwoom" + + +def live_ob_primary() -> str: + p = str(get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower() + return p if p in ("kis", "kiwoom") else "kiwoom" + + +def vendor_read_max_age_sec(caller_max_age: Optional[float]) -> Optional[float]: + """벤더 1회 조회에 쓸 나이. + + - caller None = 마지막 RAM(명시, 나이 무시) + - caller 0/음수 또는 생략 대체 = 폴백 나이(기본 3초) + - caller 양수 = min(caller, 폴백나이). 폴백 0이면 caller 그대로. + """ + fb = live_feed_fallback_max_age_sec() + if caller_max_age is None: + return None + try: + c = float(caller_max_age) + except (TypeError, ValueError): + c = 0.0 + if fb <= 0: + return None if c <= 0 else c + if c <= 0: + return fb + return min(c, fb) + + +def tick_feed_tier(vendor: str, primary: str) -> int: + v = str(vendor or "").strip().lower() + p = str(primary or "kiwoom").strip().lower() + if v in ("kiwoom_rest", "rest", "ka10007"): + return 4 + if v == "ls": + return 3 + if v == p: + return 1 + return 2 + + +def format_vendor_label(vendor: str, tier: int = 0, spilled: bool = False) -> str: + v = str(vendor or "").strip().lower() or "?" + names = { + "kiwoom": "kiwoom", + "kis": "kis", + "ls": "ls", + "kiwoom_rest": "kiwoom_rest", + "rest": "kiwoom_rest", + "ka10007": "kiwoom_rest", + } + name = names.get(v, v) + extra: List[str] = [] + tmap = {1: "1차", 2: "2차", 3: "3차", 4: "4차"} + tlab = tmap.get(int(tier or 0), "") + if tlab: + extra.append(tlab) + if spilled: + extra.append("spill") + if extra: + return f"{name}({','.join(extra)})" + return name + + +def format_mm_feed_line(tick_lab: str, ob_lab: str) -> str: + t = str(tick_lab or "").strip() or "?" + o = str(ob_lab or "").strip() or "?" + return f"시세: {t} | 호가: {o}" + + +def _tick_second_key(tick: Dict[str, Any]) -> str: + tt = str(tick.get("tick_time") or "")[:14] + if len(tt) >= 14: + return tt[:14] + if len(tt) >= 12: + return tt[:12] + "00" + return tt + + +def merge_ticks_time_axis_fallback( + ticks: Sequence[Dict[str, Any]], + *, + main_src: str = "kiwoom", + max_lag_sec: Optional[float] = None, +) -> List[Dict[str, Any]]: + """같은 초에는 메인만. 메인·보조 모두 lag>나이면 그 초는 비움(OHLC 메우지 않음). + + 분봉에 메인 1건 있다고 보조를 통째로 버리지 않음 (실매 2초 실패→2차와 동일). + """ + if not ticks: + return list(ticks) + main = str(main_src or "kiwoom").strip().lower() + if main not in ("kis", "kiwoom"): + main = "kiwoom" + age = live_feed_fallback_max_age_sec() if max_lag_sec is None else float(max_lag_sec) + grouped: Dict[str, List[Dict[str, Any]]] = {} + order: List[str] = [] + for t in ticks: + if not isinstance(t, dict): + continue + sk = _tick_second_key(t) + if not sk: + continue + if sk not in grouped: + grouped[sk] = [] + order.append(sk) + grouped[sk].append(t) + out: List[Dict[str, Any]] = [] + for sk in order: + bucket = grouped[sk] + mains: List[Dict[str, Any]] = [] + aux: List[Dict[str, Any]] = [] + for t in bucket: + src = str(t.get("source") or "").strip().lower() + lag = t.get("_lag_sec") + if is_feed_read_stale(lag, age): + continue + if src == main: + mains.append(t) + else: + aux.append(t) + if mains: + out.extend(mains) + else: + out.extend(aux) + return out + + +def orderbook_row_lag_seconds(row: Dict[str, Any]) -> Optional[int]: + """recv_ts vs snap_time 초 차이. 파싱 실패면 None(유효로 봄).""" + recv_ts = str(row.get("recv_ts") or "").strip() + st = str(row.get("snap_time") or "").strip() + if len(recv_ts) < 19 or len(st) < 6: + return None + try: + recv_dt = datetime.strptime(recv_ts[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return None + try: + if len(st) >= 14 and st[:14].isdigit(): + pkt = datetime.strptime(st[:14], "%Y%m%d%H%M%S") + elif len(st) >= 6 and st[-6:].isdigit(): + pkt = datetime.strptime(recv_ts[:10].replace("-", "") + st[-6:], "%Y%m%d%H%M%S") + else: + return None + return int((recv_dt - pkt).total_seconds()) + except Exception: + return None + + +def candle_garbage_fallback_enabled() -> bool: + try: + return bool(get_env_bool("CANDLE_GARBAGE_FALLBACK", True)) + except Exception: + return True + + +def bar_end_datetime(candle_time: str, tf_min: int): + """봉 시작 candle_time(YYYYMMDDHHMM) + tf → 봉 끝 datetime.""" + from kis_trader.engine.candle_rollup import add_candle_minutes + + end = add_candle_minutes(str(candle_time or "")[:12], int(tf_min or 1)) + if not end or len(end) < 12: + return None + try: + return datetime.strptime(end[:12], "%Y%m%d%H%M") + except Exception: + return None + + +def tick_in_bar_bucket(tick_time: str, candle_time: str, tf_min: int) -> bool: + from kis_trader.engine.candle_rollup import add_candle_minutes + + ct = str(candle_time or "").strip()[:12] + raw = str(tick_time or "").strip().replace(":", "").replace("-", "").replace(" ", "") + if len(ct) < 12: + return False + tmin = raw[:12] if len(raw) >= 12 else "" + if len(tmin) < 12 or not tmin.isdigit(): + return False + end = add_candle_minutes(ct, int(tf_min or 1)) + if not end: + return False + return ct <= tmin < end[:12] + + +def bar_is_garbage( + ticks: Sequence[Dict[str, Any]], + *, + candle_time: str, + tf_min: int, + source: str, + missing_policy: str = "hole", +) -> bool: + """그 분·그 source 틱이 없거나 전부 봉끝 대비 읽기나이 초과면 True(구멍). + + missing_policy: + hole — 0건이면 쓰레기 (옵투나 공책) + keep — 0건이면 유지 (실매 링이 그 분을 커버 못 할 때 호출측에서 keep) + 증권사 시계끼리 비교하지 않음. + """ + if not candle_garbage_fallback_enabled(): + return False + src = str(source or "").strip().lower() + bucket: List[Dict[str, Any]] = [] + for t in ticks or []: + if not isinstance(t, dict): + continue + tsrc = str(t.get("source") or "").strip().lower() + if tsrc and src and tsrc != src: + continue + raw = t.get("tick_time_raw") or t.get("tick_time") or "" + if tick_in_bar_bucket(str(raw), candle_time, tf_min): + bucket.append(t) + if not bucket: + return str(missing_policy or "hole").strip().lower() == "hole" + bar_end = bar_end_datetime(candle_time, tf_min) + if bar_end is None: + return False + for t in bucket: + raw = str(t.get("tick_time_raw") or t.get("tick_time") or "") + lag = packet_lag_seconds(raw, now_dt=bar_end) + if not is_feed_read_stale(lag): + return False + return True + + +def live_bar_is_garbage( + ticks: Sequence[Dict[str, Any]], + *, + candle_time: str, + tf_min: int, + source: str, +) -> bool: + """실매 링버퍼: 그 소스 틱이 그 분까지 없으면 판정 불가 → False(유지). + + LS 틱은 TickRecorder 링에 안 들어갈 수 있음. 다른 증권사 틱만으로 cover 하면 + LS/2차 봉을 구멍으로 잘못 버린다. 소스별 cover 만 본다. + """ + if not candle_garbage_fallback_enabled(): + return False + ct = str(candle_time or "")[:12] + if len(ct) < 12: + return False + src = str(source or "").strip().lower() + cover = False + for t in ticks or []: + if not isinstance(t, dict): + continue + tsrc = str(t.get("source") or "").strip().lower() + if tsrc and src and tsrc != src: + continue + raw = str(t.get("tick_time_raw") or t.get("tick_time") or "").strip() + digits = raw.replace(":", "").replace("-", "").replace(" ", "") + tmin = digits[:12] if len(digits) >= 12 else "" + if tmin and tmin.isdigit() and tmin <= ct: + cover = True + break + if not cover: + return False + return bar_is_garbage( + ticks, candle_time=candle_time, tf_min=tf_min, source=source, + missing_policy="hole", + ) diff --git a/kis_trader/network/ws_manager.py b/kis_trader/network/ws_manager.py index 02f1f24..67ee41c 100644 --- a/kis_trader/network/ws_manager.py +++ b/kis_trader/network/ws_manager.py @@ -1,18 +1,26 @@ """ -kis_trader/network/ws_manager.py — 단일 WebSocket 허브 (Event Bus) +kis_trader/network/ws_manager.py — WebSocket 허브 (Event Bus) ==================================================================== 설계 목적: - * 두 전략(스캘핑/꼬리잡기)이 각자 WS 연결을 띄우면 같은 종목 2번 구독 → 토큰/approval_key - 경합 + 계정 차단 위험 → 프로세스 전체에서 **단 하나의 WS 세션**만 띄운다. + * 전략마다 WS 를 띄우면 같은 종목 중복 구독 → 토큰/approval 경합. + 프로세스에서 증권사별 세션은 **공유 1개씩**(한투·키움·LS). "소켓이 하나"는 아님. * ``kis_trader.ws.kis_ws`` — ``KISWebSocketPriceCache`` + ``CandleAggregator`` 재사용. * 전략별 "구독 관심 종목" 을 **레퍼런스 카운팅**으로 관리. 한 전략이 구독 해제해도 다른 전략이 구독 중이면 WS 에서 해제되지 않는다. +구독 명단 vs 현재가 읽기 (섞지 말 것): + * ``WS_SUBSCRIBE_KIS_MINIMAL`` — 한투 소켓에 **누구를 넣을지**. + true: 한투=보유만, 후보=키움. false: 한투도 후보∪보유(41 한도). + ``LIVE_TICK_PROVIDER`` 를 kis 로 바꿔도 이 명단은 안 바뀜. + * ``LIVE_TICK_PROVIDER`` — ``get_price()`` 가 **어느 RAM 을 먼저 볼지**. + MINIMAL=true 이면 한투 RAM 은 보유만 채워짐. 후보 현재가는 한투에 없음 → 키움 폴백. + 공용 API: - start() / stop() - subscribe(code, owner) / unsubscribe(code, owner) (레거시 ref-count; ``WS_SUBSCRIBE_KIS_MINIMAL`` 시 비활성) - sync_targets(owner, codes) : 레거시 한 덩어리 동기화 (분리 모드에선 미사용) - - sync_targets_split(owner, candidates, holdings) : KIS 최소 구독 모드용 후보/보유 분리 + - sync_targets_split(owner, candidates, holdings) : ``WS_SUBSCRIBE_KIS_MINIMAL`` 시 + 후보=키움, 한투=보유만. 영구구독 KR은 LS (한투 41에 안 넣음) - set_kiwoom_ws / activate_split_feed : Orchestrator 가 키움 인스턴스 주입 후 분리 구독 ON - get_price(code) / get_candles(code, tf, n) - fill_gap(codes=None) : 갭 보정 (WS 연결 직후 자동 + 수동 호출) @@ -26,6 +34,9 @@ import time from collections import defaultdict from typing import Any, Dict, Iterable, List, Optional, Set +# get_price() 인자 생략 시 env 기본. None 은 마지막 RAM(나이 무시). +_WS_PRICE_AGE_OMITTED = object() + from ..utils.env import get_env_bool, get_env_from_db, get_env_int, get_env_float from ..utils.logger import get_logger @@ -46,6 +57,7 @@ try: get_kiwoom_candles_df, fetch_kiwoom_stock_meta, fetch_kiwoom_stock_meta_detail, + fetch_kiwoom_cur_prc_ka10007, ) from ..ws.tick_recorder import TickRecorder from ..ws.trigger_snapshot_recorder import TriggerSnapshotRecorder @@ -66,12 +78,18 @@ except ImportError as _e: get_kiwoom_candles_df = None # type: ignore[assignment] fetch_kiwoom_stock_meta = None # type: ignore[assignment] fetch_kiwoom_stock_meta_detail = None # type: ignore[assignment] + fetch_kiwoom_cur_prc_ka10007 = None # type: ignore[assignment] logger.warning( "kis_ws 에 키움 함수 없음 → 갭보정 키움 fallback 비활성 " "(KIS 1분봉 전용): %s", _e, ) except ImportError as _e2: logger.warning("kis_ws 모듈 import 실패 → WS 기능 비활성: %s", _e2) + _get_kiwoom_creds = None # type: ignore[assignment] + get_kiwoom_candles_df = None # type: ignore[assignment] + fetch_kiwoom_stock_meta = None # type: ignore[assignment] + fetch_kiwoom_stock_meta_detail = None # type: ignore[assignment] + fetch_kiwoom_cur_prc_ka10007 = None # type: ignore[assignment] class WSManager: @@ -95,7 +113,7 @@ class WSManager: self.tick_recorder: Optional["TickRecorder"] = None self.trigger_snapshot_recorder: Optional["TriggerSnapshotRecorder"] = None self.trigger_eval_recorder: Optional["TriggerEvalRecorder"] = None - # KIS 최소 구독 모드: 후보는 키움 WS, KIS 는 영구+보유만 (세션 41 한도 완화) + # WS_SUBSCRIBE_KIS_MINIMAL: 후보=키움, 한투=보유만(세션 41). 영구 KR은 LS. self._kiwoom_ws: Any = None self._split_feed_active: bool = False @@ -109,7 +127,7 @@ class WSManager: # 영구 구독(시장방향 ETF 등) self._permanent_codes: Set[str] = set() self._permanent_reload_ts: float = 0.0 - # 후보/보유 이탈 후 키움 틱 구독 유지 (만료 epoch) — KIS 41 영구구독과 분리 + # 후보/보유 이탈 후 키움 틱 구독 유지 (만료 epoch). 한투 41·영구(LS)와 별개. self._grace_until: Dict[str, float] = {} # grace 1회 소진 후 재연장 방지 (재진입 시 discard) self._grace_exhausted: Set[str] = set() @@ -165,6 +183,11 @@ class WSManager: self._tick_home: Dict[str, str] = {} self._ob_home: Dict[str, str] = {} self._tick_home_spill: Set[str] = set() # spill로 잡힌 코드 (MM 표기용) + self._ob_home_spill: Set[str] = set() + # get_price / get_orderbook_snapshot 이 실제로 쓴 벤더 (MM 시세|호가 줄) + self._last_tick_read: Dict[str, Dict[str, Any]] = {} + self._last_ob_read: Dict[str, Dict[str, Any]] = {} + self._feed_read_lock = threading.Lock() # ------------------------------------------------------------------ # 시작/종료 @@ -195,6 +218,8 @@ class WSManager: tfs = self._resolve_timeframes() self.candle_agg = CandleAggregator(db=self.db, timeframes=tfs) self.ws_cache.attach_candle_aggregator(self.candle_agg) + # 메인 세션은 체결(H0STCNT0)만. 호가(H0STASP0)는 2번째 키 전용 세션. + self.ws_cache._ws_role = "tick" if get_env_bool("WS_TICK_SAVE_KIS", True) or get_env_bool("WS_TICK_SAVE_KIWOOM", True): try: @@ -206,30 +231,45 @@ class WSManager: self.tick_recorder = None # KIS 2번째 호가 전용 웹소켓 기동 - ob_key = (get_env_from_db("KIS_APP_KEY_OB_MOCK" if is_mock else "KIS_APP_KEY_OB_REAL", "") or "").strip() - ob_secret = (get_env_from_db("KIS_APP_SECRET_OB_MOCK" if is_mock else "KIS_APP_SECRET_OB_REAL", "") or "").strip() - + # 호가도 시세이므로 실전 키 우선 (틱 WS와 동일). 실전 미입력이면 모의 호가키 폴백. + ob_key = (get_env_from_db("KIS_APP_KEY_OB_REAL", "") or "").strip() + ob_secret = (get_env_from_db("KIS_APP_SECRET_OB_REAL", "") or "").strip() + ob_is_mock = False + if not ob_key or not ob_secret: + ob_key = (get_env_from_db("KIS_APP_KEY_OB_MOCK", "") or "").strip() + ob_secret = (get_env_from_db("KIS_APP_SECRET_OB_MOCK", "") or "").strip() + ob_is_mock = bool(ob_key and ob_secret) + if ob_key and ob_secret: try: - logger.info(f"🔑 KIS 호가 전용(OB) WS 자격증명 로드됨 (mock={is_mock}) -> 2nd WS 연결 시도") + logger.info( + "🔑 KIS 호가 전용(OB) WS 자격증명 로드됨 (ob_mock=%s, kis_mock=%s) -> 2nd WS 연결 시도", + ob_is_mock, is_mock, + ) self.kis_ws_ob = KISWebSocketPriceCache( ob_key, ob_secret, - is_mock=is_mock, + is_mock=ob_is_mock, + approval_slot="ob", ) if not self.kis_ws_ob.start(): - logger.warning("KIS 호가 전용 WS(OB) 시작 실패 -> 메인 KIS WS로 폴백") - self.kis_ws_ob = self.ws_cache + logger.warning( + "KIS 호가 전용 WS(OB) 시작 실패 → 메인 세션에 ASP0 안 붙임 " + "(2키 전용. 시세 41 합산 금지)" + ) + self.kis_ws_ob = None else: - # 시세 메인 / 호가 전용 역할 분리 (기존 SAVE DB값 변경 없음) - self.ws_cache._ws_role = "tick" self.kis_ws_ob._ws_role = "orderbook" logger.info("✅ KIS 호가 전용 WS(OB) 정상 시작 완료 (main=tick, ob=orderbook)") except Exception as e: - logger.warning("KIS 호가 전용 WS(OB) 생성 중 오류: %s", e) - self.kis_ws_ob = self.ws_cache + logger.warning("KIS 호가 전용 WS(OB) 생성 중 오류: %s → 메인 ASP0 폴백 없음", e) + self.kis_ws_ob = None else: - self.kis_ws_ob = self.ws_cache + logger.warning( + "KIS 호가 전용 키 없음(KIS_APP_KEY_OB_REAL/MOCK) → " + "kis_ws_ob=None, 메인 H0STASP0 안 붙임" + ) + self.kis_ws_ob = None if get_env_bool("WS_TRIGGER_EVAL_SAVE_ENABLED", True): try: @@ -246,9 +286,17 @@ class WSManager: if TriggerSnapshotRecorder is not None: self.trigger_snapshot_recorder = TriggerSnapshotRecorder(db=self.db) # Orderbook Snapshot Recorder 부착 (호가용, kis_ws_ob 기준) - if self.trigger_snapshot_recorder and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False): + if ( + self.trigger_snapshot_recorder + and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) + and self.kis_ws_ob is not None + ): self.kis_ws_ob.attach_trigger_snapshot_recorder(self.trigger_snapshot_recorder) logger.info("✅ KIS 호가 WS(OB)에 TriggerSnapshotRecorder 부착 완료 (KIS 호가 → kis_ws_orderbook 적재)") + elif get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) and self.kis_ws_ob is None: + logger.warning( + "WS_ORDERBOOK_SAVE_KIS=ON 이나 2키 OB 세션 없음 → 적재 생략" + ) except Exception as ts_ex: logger.warning("TriggerSnapshotRecorder 기동 실패: %s", ts_ex) self.trigger_snapshot_recorder = None @@ -279,6 +327,16 @@ class WSManager: # 연결 성공 후 자동 갭 보정 등록 (WS 재접속 시 전체 재갭보정) self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async) self._reattach_all_price_listeners() + try: + if self.candle_agg is not None: + if self.tick_recorder is not None: + self.candle_agg.set_tick_lookup( + lambda c: self.tick_recorder.get_recent_ticks(c, limit=8000) + if self.tick_recorder else [] + ) + self.candle_agg.set_ls_bars_fn(self._ls_confirmed_bars_ram) + except Exception: + pass logger.info( "✅ WSManager 활성 (tfs=%s, permanent=%d, gap_workers=%d)", @@ -293,6 +351,11 @@ class WSManager: return False def stop(self) -> None: + try: + if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache: + self.kis_ws_ob.stop(clear_subscriptions=True) + except Exception as e: + logger.debug("KIS OB WS stop 실패: %s", e) try: if self.ws_cache: self.ws_cache.stop(clear_subscriptions=True) @@ -304,7 +367,7 @@ class WSManager: return bool(self.ws_cache and self.ws_cache.is_active) # ------------------------------------------------------------------ - # 키움 분리 시세 (KIS=영구+보유, 후보=키움) — Orchestrator 가 주입 + # 키움 분리 시세 (MINIMAL: 한투=보유, 후보=키움, 영구KR=LS) — Orchestrator 가 주입 # ------------------------------------------------------------------ def set_kiwoom_ws(self, kiwoom_ws: Any) -> None: """키움 WS 인스턴스 (기동 후 주입). ``activate_split_feed`` 전에 설정.""" @@ -323,10 +386,14 @@ class WSManager: self._kiwoom_ws.subscribe(code) def activate_split_feed(self, active: bool) -> None: - """``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보 구독을 키움으로.""" + """``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보=키움, 한투=보유만. + + ``LIVE_TICK_PROVIDER`` 와 무관. 메인을 한투로 바꿔도 후보 구독은 키움. + 영구구독 KR은 LS. 한투 41은 영구 슬롯이 아님. + """ self._split_feed_active = bool(active and self._kiwoom_ws and self.ws_cache) if self._split_feed_active: - logger.info("✅ WS 분리 시세 활성: KIS=PERMANENT∪보유, 후보·검증=키움") + logger.info("✅ WS 분리 시세 활성: 한투=보유만, 후보=키움, 영구KR=LS") def sync_targets_split( self, @@ -453,9 +520,49 @@ class WSManager: except Exception as e: logger.debug("LS permanent sync 실패: %s", e) + def _sync_feed_fallback_to_ls(self, codes: "Set[str]") -> None: + """메인 틱/호가 유니버스를 LS 3차 RAM(시세+UH1)에 붙임. 갭 REST 아님. + + owner=_feed_fallback. 영구는 _permanent 와 합집합. 마스터 OFF 여도 폴백 구독은 독립. + """ + if not get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", True): + codes = set() + ls_ws = self._get_ls_ws() + if ls_ws is None: + return + try: + ls_ws.sync_owner_codes("_feed_fallback", codes or set()) + except Exception as e: + logger.debug("LS feed-fallback sync 실패: %s", e) + + def _ls_ram_universe_codes(self) -> Set[str]: + """LS RAM 미러 대상: 후보∪보유∪영구∪grace. MINIMAL 여부와 무관.""" + with self._lock: + perm = set(self._permanent_codes) + if self._split_feed_active: + cand_u: Set[str] = set() + for s in self._owner_candidates.values(): + cand_u |= s + hold_u: Set[str] = set() + for s in self._owner_holdings.values(): + hold_u |= s + out = cand_u | hold_u | perm + else: + out = set(self._code_refs.keys()) | perm + grace = self._purge_and_get_grace_codes() + if grace: + out |= set(grace) + return {str(c).strip() for c in out if str(c).strip()} + def _enqueue_ls_gap_fill(self, code: str, *, force: bool = False, priority: bool = False) -> None: if not code or not get_env_bool("LS_GAP_FILL_ENABLED", True): return + # 후보 갭 REST 기본 OFF — 폴백 구독만. 영구는 기존 경로. + if not get_env_bool("LS_GAP_FILL_CANDIDATES", False): + with self._lock: + perm = set(self._permanent_codes) + if str(code).strip() not in perm: + return fail_max = max(1, get_env_int("LS_GAP_FILL_FAIL_MAX", 5)) with self._ls_gap_lock: if self._ls_gap_fail.get(code, 0) >= fail_max and not force: @@ -532,7 +639,7 @@ class WSManager: def _normalize_feed_vendor(self, raw: str, default: str = "kiwoom") -> str: v = (raw or default).strip().lower() - if v in ("kis", "kiwoom", "ls"): + if v in ("kis", "kiwoom", "ls", "kiwoom_rest"): return v if v in ("ls_condition", "ls_ws", "ls_afr"): return "ls" @@ -646,7 +753,7 @@ class WSManager: return False def _try_subscribe_vendor_ob(self, vendor: str, code: str, *, spill: bool) -> bool: - """호가 구독. KIS는 kis_ws_ob(역할 orderbook) 또는 SAVE 시 메인.""" + """호가 구독. KIS는 2키 kis_ws_ob(역할 orderbook)만. 메인 ASP0 합산 금지.""" v = self._normalize_feed_vendor(vendor, "") code = (code or "").strip() if not code or not v: @@ -660,21 +767,15 @@ class WSManager: return True return self._try_subscribe_vendor_tick("kiwoom", code, spill=spill) if v == "kis": - ob = self.kis_ws_ob or self.ws_cache - if ob is None: + ob = self.kis_ws_ob + if ob is None or ob is self.ws_cache: return False if spill and not getattr(ob, "is_active", False): return False - # 별도 OB 세션이면 그쪽만; 아니면 메인 subscribe(SAVE 시 ASP0) - if ob is not self.ws_cache: - with ob._sub_lock: - if code in ob._subscribed: - return True - return bool(ob.subscribe(code)) - if not get_env_bool("WS_ORDERBOOK_SAVE_KIS", False): - # 메인에 호가 TR 안 붙는 설정 → kis 호가 항 스킵 - return False - return self._try_subscribe_vendor_tick("kis", code, spill=spill) + with ob._sub_lock: + if code in ob._subscribed: + return True + return bool(ob.subscribe(code)) if v == "ls": return self._try_subscribe_vendor_tick("ls", code, spill=spill) except Exception as e: @@ -706,6 +807,10 @@ class WSManager: return with self._lock: self._ob_home[code] = v + if spilled: + self._ob_home_spill.add(code) + else: + self._ob_home_spill.discard(code) if spilled: logger.info( "📡 [spill ob] %s → %s reason=%s", @@ -761,8 +866,8 @@ class WSManager: cur = self._ob_home.get(code) if cur: if cur == "kis": - ob = self.kis_ws_ob or self.ws_cache - if ob is not None: + ob = self.kis_ws_ob + if ob is not None and ob is not self.ws_cache: try: with ob._sub_lock: if code in ob._subscribed: @@ -794,19 +899,142 @@ class WSManager: logger.warning("⚠️ [spill ob] %s 최종 거절 (prefer=%s reason=%s)", code, prefer, reason) return None - def get_tick_feed_label(self, code: str) -> str: - """매수/매도 MM 표기용 — 예: kiwoom / kis / ls(spill).""" + def _remember_feed_read( + self, kind: str, code: str, vendor: str, *, spilled: bool = False, + ) -> None: code = (code or "").strip() + v = self._normalize_feed_vendor(vendor, vendor or "") + if not code or not v: + return + try: + from kis_trader.engine.feed_fallback import tick_feed_tier, format_vendor_label + primary = ( + self._normalize_feed_vendor( + get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom", + "kiwoom", + ) + if kind == "ob" + else self._normalize_feed_vendor( + get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom", + "kiwoom", + ) + ) + tier = tick_feed_tier(v, primary) + rec = { + "vendor": v, + "tier": tier, + "spilled": bool(spilled), + "label": format_vendor_label(v, tier, spilled), + "ts": time.time(), + } + except Exception: + rec = {"vendor": v, "tier": 0, "spilled": bool(spilled), "label": v, "ts": time.time()} + with self._feed_read_lock: + if kind == "ob": + self._last_ob_read[code] = rec + else: + self._last_tick_read[code] = rec + + def _label_from_read(self, rec: Optional[dict], *, kind: str, code: str) -> str: + if rec and rec.get("label"): + return str(rec.get("label") or "") + try: + from kis_trader.engine.feed_fallback import format_vendor_label, tick_feed_tier + except Exception: + format_vendor_label = None # type: ignore[assignment] + tick_feed_tier = None # type: ignore[assignment] with self._lock: - home = self._tick_home.get(code) - spilled = code in self._tick_home_spill - if home: - return f"{home}(spill)" if spilled else home - # home 미확정: LIVE 기본 - return self._normalize_feed_vendor( - get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom", + home = self._ob_home.get(code) if kind == "ob" else self._tick_home.get(code) + spilled = ( + code in self._ob_home_spill if kind == "ob" + else code in self._tick_home_spill + ) + primary = self._normalize_feed_vendor( + get_env_from_db( + "LIVE_OB_PROVIDER" if kind == "ob" else "LIVE_TICK_PROVIDER", + "kiwoom", + ) or "kiwoom", "kiwoom", ) + if home: + if format_vendor_label is not None and tick_feed_tier is not None: + return format_vendor_label(home, tick_feed_tier(home, primary), spilled) + return f"{home}(spill)" if spilled else str(home) + if format_vendor_label is not None and tick_feed_tier is not None: + return format_vendor_label(primary, tick_feed_tier(primary, primary), False) + return primary + + def get_tick_feed_label(self, code: str) -> str: + """매수/매도 MM 표기용 — 예: kiwoom(1차) / kis(2차,spill) / ls(3차).""" + code = (code or "").strip() + with self._feed_read_lock: + rec = dict(self._last_tick_read.get(code) or {}) + return self._label_from_read(rec, kind="tick", code=code) + + def get_ob_feed_label(self, code: str) -> str: + """매수/매도 MM 표기용 호가 소스.""" + code = (code or "").strip() + with self._feed_read_lock: + rec = dict(self._last_ob_read.get(code) or {}) + return self._label_from_read(rec, kind="ob", code=code) + + def _vendor_price(self, vendor: str, code: str, max_age_sec: Optional[float]): + v = self._normalize_feed_vendor(vendor, "") + try: + if v == "kiwoom" and self._kiwoom_ws: + return self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec) + if v == "kis" and self.ws_cache: + return self.ws_cache.get_price(code, max_age_sec=max_age_sec) + if v == "ls": + ls_ws = self._get_ls_ws() + if ls_ws is not None: + return ls_ws.get_price(code, max_age_sec=max_age_sec) + except Exception: + return None + return None + + def _vendor_orderbook_snap(self, vendor: str, code: str, max_age_sec: float): + v = self._normalize_feed_vendor(vendor, "") + try: + if v == "kiwoom" and self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"): + return self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) + if v == "kis": + kis_src = self.kis_ws_ob + if kis_src is None or kis_src is self.ws_cache: + return None + if hasattr(kis_src, "get_orderbook_snapshot"): + return kis_src.get_orderbook_snapshot(code, max_age_sec=max_age_sec) + return None + if v == "ls": + ls_ws = self._get_ls_ws() + if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"): + return ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) + except Exception: + return None + return None + + def inquire_kiwoom_rest_price(self, code: str) -> float: + """4차 키움 ka10007 현재가. 한투 inquire_price(60초 캐시) 금지. + + 시세 실키(KIWOOM_WS_FORCE_REAL). 매매 KIS_MOCK 모의키를 쓰지 않음. + """ + code = str(code or "").strip() + if not code: + return 0.0 + fn = fetch_kiwoom_cur_prc_ka10007 + creds = _get_kiwoom_creds + if not callable(fn) or not callable(creds): + return 0.0 + try: + key, secret, is_mock = creds(self.db) + except Exception: + return 0.0 + if not key or not secret: + return 0.0 + try: + return float(fn(code, key, secret, is_mock=bool(is_mock)) or 0.0) + except Exception: + return 0.0 def _clear_homes_if_unsubscribed(self, code: str) -> None: code = (code or "").strip() @@ -825,9 +1053,9 @@ class WSManager: self._tick_home.pop(code, None) self._tick_home_spill.discard(code) if oh == "kis": - ob = self.kis_ws_ob or self.ws_cache + ob = self.kis_ws_ob try: - if ob is None: + if ob is None or ob is self.ws_cache: gone = True else: with ob._sub_lock: @@ -837,12 +1065,17 @@ class WSManager: if gone: with self._lock: self._ob_home.pop(code, None) + self._ob_home_spill.discard(code) elif oh and not self._code_subscribed_on(oh, code): with self._lock: self._ob_home.pop(code, None) + self._ob_home_spill.discard(code) def _reconcile_split_subscriptions(self) -> None: - """KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화.""" + """MINIMAL 재동기화: 키움=후보∪보유, 한투=보유만, 영구KR=LS (한투/키움 슬롯 제외). + + LIVE_TICK 을 읽지 않음. 메인 벤더와 구독 명단은 별개. + """ if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws): return # permanent_subscriptions 테이블 갱신 반영 (보유 해제 후에도 영구구독 틱 유지) @@ -960,7 +1193,7 @@ class WSManager: self._subscribe_ob_prefer_or_spill(code, "kis") for code in sorted(kis_now - kis_want): - # KIS 는 grace 미적용 (영구+보유만) — 즉시 해제 + # 한투는 grace 없음(MINIMAL에선 보유만) — want 밖이면 즉시 해제 self.ws_cache.unsubscribe(code) if self.kis_ws_ob is not None and self.kis_ws_ob is not self.ws_cache: try: @@ -999,6 +1232,7 @@ class WSManager: # 영구구독 코드를 LS WS에 sync (KIS/키움 슬롯 절약 — LS는 RAM 전용) self._sync_permanent_to_ls(perm) + self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes()) def _ws_grace_sec(self) -> int: # 후보/보유 이탈 후에도 시세 피드 유지 (기본 180초, 3분) @@ -1226,6 +1460,7 @@ class WSManager: for code in sorted(new_set - cur): self.subscribe(code, owner) self._sync_tick_record_codes() + self._sync_feed_fallback_to_ls(self._ls_ram_universe_codes()) def get_recent_ticks(self, code: str, limit: int = 100) -> list: """RAM 링버퍼 최근 체결 틱 (B안 봉 내 돌파 등).""" @@ -1239,92 +1474,56 @@ class WSManager: # ------------------------------------------------------------------ # 조회 헬퍼 (전략이 쓰는 API) # ------------------------------------------------------------------ - def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]: - # ls_condition 전략 코드 → LS WS 우선 (LS feed 코드는 여기서만 조회) - if self.is_ls_feed_code(code): - ls_ws = self._get_ls_ws() - if ls_ws is not None: - try: - p = ls_ws.get_price(code, max_age_sec=max_age_sec) - if p: - return p - except Exception: - pass - else: - logger.debug("LS 피드 코드이나 LS WS 없음: %s", code) - # spill home 우선 (있으면 그 벤더 RAM 먼저 — sleep 없음) + def get_price( + self, code: str, max_age_sec: Any = _WS_PRICE_AGE_OMITTED, + ) -> Optional[dict]: + """현재가. 인자 생략 = 읽기 폴백 나이(LIVE_FEED_FALLBACK, 기본 3초)로 메인→2차→3차. + + ``max_age_sec=None`` 은 마지막 RAM(나이 무시, 진단용). + 메인이 폴백 나이 넘으면 없는 것과 같고 체인을 계속한다. 4차 REST 는 여기 없음. + """ + from kis_trader.engine.feed_fallback import vendor_read_max_age_sec + + if max_age_sec is None: + caller_age: Optional[float] = None + elif max_age_sec is _WS_PRICE_AGE_OMITTED: + try: + ws_age = float(get_env_float("WS_PRICE_MAX_AGE_SEC", 0.0) or 0.0) + except Exception: + ws_age = 0.0 + caller_age = ws_age + else: + try: + caller_age = float(max_age_sec) + except (TypeError, ValueError): + caller_age = 0.0 + resolved_age = vendor_read_max_age_sec(caller_age) + primary = self._normalize_feed_vendor( + get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom", + "kiwoom", + ) with self._lock: home = self._tick_home.get(code) - if home: - try: - if home == "kiwoom" and self._kiwoom_ws: - p = self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec) - if p: - return p - elif home == "kis" and self.ws_cache: - p = self.ws_cache.get_price(code, max_age_sec=max_age_sec) - if p: - return p - elif home == "ls": - ls_ws = self._get_ls_ws() - if ls_ws is not None: - p = ls_ws.get_price(code, max_age_sec=max_age_sec) - if p: - return p - except Exception: - pass - from ..utils.env import get_env_from_db - live_tick_provider = (get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower() - - if live_tick_provider == "kiwoom": - # 1. 키움 우선 - if self._kiwoom_ws: - try: - p = self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec) - if p: return p - except Exception: - pass - # 2. KIS WS 폴백 - if self.ws_cache: - try: - p = self.ws_cache.get_price(code, max_age_sec=max_age_sec) - if p: return p - except Exception: - pass - # 3. LS WS 폴백 (RAM 캐시, DB 적재 없음 — 영구구독 코드 포함) - if not self.is_ls_feed_code(code): - ls_ws = self._get_ls_ws() - if ls_ws is not None: - try: - p = ls_ws.get_price(code, max_age_sec=max_age_sec) - if p: return p - except Exception: - pass + spilled = code in self._tick_home_spill + chain: List[str] = [] + if self.is_ls_feed_code(code): + chain = ["ls"] else: - # 1. KIS 우선 - if self.ws_cache: - try: - p = self.ws_cache.get_price(code, max_age_sec=max_age_sec) - if p: return p - except Exception: - pass - # 2. 키움 폴백 - if self._kiwoom_ws: - try: - p = self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec) - if p: return p - except Exception: - pass - # 3. LS WS 폴백 - if not self.is_ls_feed_code(code): - ls_ws = self._get_ls_ws() - if ls_ws is not None: - try: - p = ls_ws.get_price(code, max_age_sec=max_age_sec) - if p: return p - except Exception: - pass - + if home and home not in chain: + chain.append(home) + for v in (primary, "kiwoom", "kis", "ls"): + if v and v not in chain: + chain.append(v) + for v in chain: + p = self._vendor_price(v, code, resolved_age) + if p: + if isinstance(p, dict): + p = dict(p) + p["_feed_vendor"] = v + self._remember_feed_read( + "tick", code, v, spilled=bool(spilled and v == home), + ) + return p return None def register_price_listener(self, callback) -> None: @@ -1362,196 +1561,81 @@ class WSManager: for cb in list(self._price_listeners): self._attach_price_listener(cb) - def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0): - """호가 스냅샷 — ls_condition 코드는 LS UH1 RAM 우선 (키움 폴백 없음).""" - if self.is_ls_feed_code(code): - ls_ws = self._get_ls_ws() - if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"): - try: - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: - return snap - except Exception: - pass - return None + def get_price_last(self, code: str) -> Optional[dict]: + """마지막 체결 RAM. 나이 무시 (진단·EOD). 매수·매도 판정은 get_price() 폴백 체인.""" + return self.get_price(code, max_age_sec=None) - # spill ob_home 우선 + def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0): + """호가 스냅샷. 벤더별 폴백 나이 실패 시 2차→3차. 만료로 None 만 만들고 끝내지 않음. + + ls_condition 코드는 LS UH1 만 (키움으로 메우지 않음). + """ + from kis_trader.engine.feed_fallback import vendor_read_max_age_sec + + try: + caller = float(max_age_sec) + except (TypeError, ValueError): + caller = 0.0 + vendor_age = vendor_read_max_age_sec(caller) + if vendor_age is None: + vendor_age = 0.0 + primary = self._normalize_feed_vendor( + get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom", + "kiwoom", + ) with self._lock: home = self._ob_home.get(code) - if home: - try: - if home == "kiwoom" and self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"): - snap = self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: - return snap - elif home == "kis": - kis_src = self.kis_ws_ob or self.ws_cache - if kis_src and hasattr(kis_src, "get_orderbook_snapshot"): - snap = kis_src.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: - return snap - elif home == "ls": - ls_ws = self._get_ls_ws() - if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"): - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: - return snap - except Exception: - pass - - from ..utils.env import get_env_from_db - live_ob_provider = (get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower() - - kis_src = self.kis_ws_ob or self.ws_cache - - if live_ob_provider == "kiwoom": - # 1. 키움 우선 - if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"): - try: - snap = self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: return snap - except Exception: - pass - # 2. KIS 폴백 - if kis_src and hasattr(kis_src, "get_orderbook_snapshot"): - try: - snap = kis_src.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: return snap - except Exception: - pass - # 3. LS RAM 폴백 - ls_ws = self._get_ls_ws() - if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"): - try: - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: - return snap - except Exception: - pass + spilled = code in self._ob_home_spill + chain: List[str] = [] + if self.is_ls_feed_code(code): + chain = ["ls"] else: - # 1. KIS 우선 - if kis_src and hasattr(kis_src, "get_orderbook_snapshot"): + if home: + chain.append(home) + for v in (primary, "kiwoom", "kis", "ls"): + if v and v not in chain: + chain.append(v) + for v in chain: + snap = self._vendor_orderbook_snap(v, code, float(vendor_age)) + if snap is not None: try: - snap = kis_src.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: return snap + setattr(snap, "_feed_vendor", v) except Exception: pass - # 2. 키움 폴백 - if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"): - try: - snap = self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: return snap - except Exception: - pass - ls_ws = self._get_ls_ws() - if ls_ws is not None and hasattr(ls_ws, "get_orderbook_snapshot"): - try: - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None: - return snap - except Exception: - pass - + self._remember_feed_read( + "ob", code, v, spilled=bool(spilled and v == home), + ) + return snap return None def get_orderbook(self, code: str, max_age_sec: float = 3.0) -> Optional[dict]: - """호가 dict — ls_condition 코드는 LS 우선 (키움으로 메우지 않음).""" - if self.is_ls_feed_code(code): - ls_ws = self._get_ls_ws() - if ls_ws is not None: - try: - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None and hasattr(snap, "to_kis_bid_dict"): - return snap.to_kis_bid_dict() - if isinstance(snap, dict): - return snap - except Exception: - pass - return None + """호가 dict — get_orderbook_snapshot 과 같은 폴백 체인. KIS 는 dict 폴백.""" + from kis_trader.engine.feed_fallback import vendor_read_max_age_sec - with self._lock: - home = self._ob_home.get(code) - if home: + try: + caller = float(max_age_sec) + except (TypeError, ValueError): + caller = 0.0 + va = vendor_read_max_age_sec(caller) + if va is None: + va = 0.0 + snap = self.get_orderbook_snapshot(code, max_age_sec=float(va)) + if snap is not None and hasattr(snap, "to_kis_bid_dict"): try: - if home == "kiwoom" and self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook"): - ob = self._kiwoom_ws.get_orderbook(code, max_age_sec=max_age_sec) - if ob: - return ob - elif home == "kis": - kis_src = self.kis_ws_ob or self.ws_cache - if kis_src and hasattr(kis_src, "get_orderbook"): - ob = kis_src.get_orderbook(code, max_age_sec=max_age_sec) - if ob: - return ob - elif home == "ls": - ls_ws = self._get_ls_ws() - if ls_ws is not None: - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None and hasattr(snap, "to_kis_bid_dict"): - return snap.to_kis_bid_dict() - if isinstance(snap, dict): - return snap + return snap.to_kis_bid_dict() + except Exception: + pass + if isinstance(snap, dict): + return snap + kis_src = self.kis_ws_ob + if kis_src is not None and kis_src is not self.ws_cache and hasattr(kis_src, "get_orderbook"): + try: + ob = kis_src.get_orderbook(code, max_age_sec=float(va)) + if ob: + self._remember_feed_read("ob", code, "kis") + return ob except Exception: pass - - from ..utils.env import get_env_from_db - live_ob_provider = (get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower() - - kis_src = self.kis_ws_ob or self.ws_cache - - if live_ob_provider == "kiwoom": - # 1. 키움 우선 - if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook"): - try: - ob = self._kiwoom_ws.get_orderbook(code, max_age_sec=max_age_sec) - if ob: return ob - except Exception: - pass - # 2. KIS WS 폴백 - if kis_src and hasattr(kis_src, "get_orderbook"): - try: - ob = kis_src.get_orderbook(code, max_age_sec=max_age_sec) - if ob: return ob - except Exception: - pass - # 3. LS WS 폴백 (RAM 캐시, DB 적재 없음) - ls_ws = self._get_ls_ws() - if ls_ws is not None: - try: - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None and hasattr(snap, "to_kis_bid_dict"): - return snap.to_kis_bid_dict() - if isinstance(snap, dict): - return snap - except Exception: - pass - else: - # 1. KIS 우선 - if kis_src and hasattr(kis_src, "get_orderbook"): - try: - ob = kis_src.get_orderbook(code, max_age_sec=max_age_sec) - if ob: return ob - except Exception: - pass - # 2. 키움 폴백 - if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook"): - try: - ob = self._kiwoom_ws.get_orderbook(code, max_age_sec=max_age_sec) - if ob: return ob - except Exception: - pass - # 3. LS WS 폴백 - ls_ws = self._get_ls_ws() - if ls_ws is not None: - try: - snap = ls_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec) - if snap is not None and hasattr(snap, "to_kis_bid_dict"): - return snap.to_kis_bid_dict() - if isinstance(snap, dict): - return snap - except Exception: - pass - return None def get_program_snapshot(self, code: str, max_age_sec: float = 30.0): @@ -1565,6 +1649,26 @@ class WSManager: pass return None + def _ls_confirmed_bars_ram(self, code: str, tf: int) -> list: + """LS 확정봉 RAM만 (DB 재조회 없음). 분봉 3차 pick 용.""" + ls = self._get_ls_ws() + if ls is None: + return [] + c = str(code or "").strip() + tf_i = max(1, int(tf or 1)) + try: + with ls._candle_lock: + buf = list(ls._confirmed.get((c, tf_i), []) or []) + except Exception: + return [] + out = [] + for b in buf: + row = dict(b) + row["source"] = "ls" + row["channel"] = "ws" + out.append(row) + return out + def get_candles(self, code: str, tf: int, n: int = 50) -> list: # ls_condition 도 분봉은 키움 갭·candle_agg (틱만 LS) if self.candle_agg: @@ -2580,7 +2684,7 @@ class WSManager: # ------------------------------------------------------------------ def _load_permanent_codes(self) -> None: """ - 국내 WS(H0STCNT0) 영구구독 코드 로드. + 영구구독 KR 코드 로드 (구독 본체는 LS WS. 한투 H0STCNT0·키움 0B 슬롯에 안 넣음). 우선순위: permanent_subscriptions 테이블(KR·enabled) → 없으면 env PERMANENT_WS_CODES. ※ 해외(US) 코드는 국내 WS로 받을 수 없으므로 여기서 제외한다. (해외는 별도 KISOverseasWebSocket(HDFSCNT0)이 담당) diff --git a/kis_trader/web/live_config_schema.py b/kis_trader/web/live_config_schema.py index 35e1b45..37732bc 100644 --- a/kis_trader/web/live_config_schema.py +++ b/kis_trader/web/live_config_schema.py @@ -190,30 +190,68 @@ def build_live_config_groups() -> List[GroupDef]: "fields": [ _f("LIVE_TICK_PROVIDER", "📊 실매매 틱(시세) 소스 (재시작 필요)", "text", default="kiwoom", hint=( - "kiwoom (권장) = 키움증권 실시간 체결가 사용 | " - "kis = 한국투자증권 실시간 체결가 사용" + "get_price 가 어느 RAM 을 먼저 볼지(1차). 폴백 나이 넘으면 실패로 보고 " + "2차(상대 체결소켓)→3차(LS)→매도는 4차 키움 REST(ka10007). " + "한투 60초 캐시는 안 씀. MINIMAL 이면 한투 RAM 은 보유만" )), _f("LIVE_OB_PROVIDER", "📊 실매매 호가(OB) 소스 (재시작 필요)", "text", default="kiwoom", hint=( - "kiwoom (권장) = 키움증권 10단계 호가 사용 | " - "kis = 한국투자증권 10단계 호가 사용 (kis 선택 시 아래 'KIS 실시간 호가 수집' 스위치도 ON이어야 합니다)" + "호가 1차. 폴백 나이 넘으면 그 벤더는 없는 것과 같고 2차→3차(LS UH1). " + "만료로 None 만 만들어서 필터 ON 통과 구멍을 만들지 않음. " + "kis 선택 시 아래 'KIS 실시간 호가 수집' 도 ON" + )), + _f("LIVE_FEED_FALLBACK_MAX_AGE_SEC", "⏱ 시세·호가 읽기 폴백 나이(초)", "float", default=3.0, + hint=( + "비교 = 그 틱의 체결시각(키움 FID20 / 한투 chetime) vs 이 서버 지금. " + "1차·2차·3차 모두 같음. 보조가 늦으면 3차, 매도는 REST. " + "키움 시각에서 한투 시각을 빼서 늦다고 하지 않음. " + "넘기면 그 벤더 실패. 적재(공책)와 분리. 기본 3. " + "분봉 쓰레기 판정 나이도 이 값(봉 끝시각 대비)." + )), + _f("CANDLE_GARBAGE_FALLBACK", "🕯 분봉 쓰레기→2차·3차 봉 통째", "bool", default=True, + hint=( + "ON(기본)=그 분 메인 틱이 0건이거나 전부 봉끝 대비 읽기나이 초과면 " + "그 봉을 구멍으로 보고 2차 WS→LS 봉 통째. 한 봉 안 혼합 롤업 없음. " + "실매 링에 그 분이 안 들어오면 판정 불가→기존 봉 유지" )), _f("WS_TICK_SAVE_KIWOOM", "📈 키움 체결 틱 수집", "bool", default=True, hint="ON = 키움 체결 틱을 ws_ticks 테이블에 저장 (기본값: ON)"), + _f("KIWOOM_TICK_TIME_MAX_LAG_SEC", "⏱ 키움 체결시간(FID20) 동결 한도(초)", "int", default=120, + hint=( + "같은 FID20 가 이 초 이상 안 움직일 때만 아침 시계멈춤으로 보고 " + "RAM 유지·tick_time 을 수신시각으로 보정. " + "밀린 줄의 같은 초 연속 체결(수십 ms)은 동결이 아님" + )), + _f("KIWOOM_TICK_LIVE_MAX_LAG_SEC", "⏱ 키움 DB 적재 최대 지연(초)", "int", default=0, + hint=( + "0(기본)=ws_ticks 전부 적재(밀린 줄도 공책에 남김). " + "양수=FID20 vs 지금 이 초 초과만 틱 미저장. " + "매매 RAM 은 LIVE_FEED_FALLBACK(기본 3초) — 적재와 분리" + )), + _f("KIWOOM_TICK_STALE_LOG_GAP_SEC", "⏱ 키움 stale skip 로그 간격(초)", "float", default=30.0, + hint="종목당 stale skip 경고 최소 간격"), + _f("KIWOOM_WS_REG_REFRESH", "🧪 키움 REG refresh (테스트)", "int", default=1, + hint=( + "1(기본)=기존 구독 유지. 0=LOGIN 직후 일괄 REG 첫 청크만 그룹 구독 해지 후 재등록. " + "장중 종목 추가 REG는 항상 1. 장중 상시 0 비추(catch-up 재점화). " + "실매 반영은 kis_trader_main 재시작" + )), _f("WS_ORDERBOOK_SAVE_KIWOOM", "📋 키움 실시간 호가 수집", "bool", default=True, hint="ON = 키움 호가를 ws_orderbook 테이블에 저장 (기본값: ON)"), _f("WS_TICK_SAVE_KIS", "📈 KIS 체결 틱 수집", "bool", default=True, hint="ON = KIS 체결 틱을 ws_ticks 테이블에 저장 (기본값: ON)"), _f("WS_ORDERBOOK_SAVE_KIS", "📋 KIS 실시간 호가 수집", "bool", default=False, + danger=True, hint=( - "OFF(기본) = KIS 호가는 수집 안 함 (한도 40개 보호) | " - "ON = KIS 호가도 kis_ws_orderbook 테이블에 저장. " - "단, KIS는 시세+호가 합산 40개 한도라서 ON 시 최대 20종목만 추적 가능" + "한투 호가는 2번째 앱키(KIS_APP_KEY_OB_REAL) 전용 세션만. " + "메인 시세 41과 합산 금지. 2키 없으면 ON 해도 호가 구독·적재 안 함. " + "2키 세션 한도는 호가 전용 41." )), _f("WS_SUBSCRIBE_KIS_MINIMAL", "📉 KIS 최소 구독 모드", "bool", default=False, hint=( - "OFF(기본) = KIS가 키움과 동일한 후보 40종목을 전부 수신하여 DB에 저장 | " - "ON = KIS가 현재 내가 사고 있는 종목만 수신 (KIS ID 한도 아낄 때)" + "한투 소켓에 누구를 넣을지. LIVE_TICK_PROVIDER 와 무관. " + "OFF(기본)=한투도 후보·보유를 키움과 같이 구독(세션 41). " + "ON=한투는 보유만, 후보는 키움. 영구구독 KR은 LS" )), _f("WS_SUBSCRIBE_SPILL", "📡 구독초과/실패 즉시 벤더 spill", "bool", default=True, hint=( @@ -227,10 +265,39 @@ def build_live_config_groups() -> List[GroupDef]: hint="비우면 LIVE_OB_PROVIDER→나머지→ls(3차 RAM). 예: kis,kiwoom,ls"), _f("LS_WS_ENABLED", "📡 LS WS 기동 (재시작 필요)", "bool", default=False, hint=( - "ON = LS증권 실시간 WS 기동 — 구독 spill 3차·영구구독 RAM 백업용. " + "ON = LS증권 실시간 WS 기동 — 구독 spill 3차·영구구독·읽기 폴백 RAM. " "OFF(기본)=VALIDATION/조건이력/ls_condition 전략이 있을 때만 기동. " "키: env_config LS_APP_KEY_REAL / LS_APP_SECRET_REAL 필요." )), + _f("LS_FEED_FALLBACK_SUBSCRIBE", "📡 LS 3차 폴백 구독 (메인 유니버스)", "bool", default=True, + hint=( + "ON(기본)=영구 외에 메인 틱/호가 구독 집합(후보∪보유∪grace)을 LS에 시세+UH1 붙임. " + "3차가 실제로 있게 함. 후보 갭 REST 와 별개" + )), + _f("LS_WS_TICK_SAVE", "📈 LS 체결 틱 DB 적재", "bool", default=False, + hint=( + "OFF(기본)=후보 틱은 ls_ws_ticks 에 안 쌓음(옵투나 3차 틱 없음). RAM 은 구독 ON. " + "영구 ETF 틱은 persist 게이트(영구만) + 이 스위치. " + "OFF 여도 아래 LS 호가 DB 적재는 별개로 동작" + )), + _f("LS_WS_ALSO_HOGA", "📋 LS 호가(UH1) 구독 (재시작 필요)", "bool", default=True, + hint=( + "ON(기본)=LS 종목 구독 시 체결과 같이 UH1 호가도 받음. " + "OFF면 호가 RAM·ls_ws_orderbook 이 비어 3차 호가가 없음. " + "적재 스위치와 별개 — 둘 다 ON" + )), + _f("LS_WS_ORDERBOOK_SAVE", "📋 LS 호가 DB 적재", "bool", default=True, + hint=( + "ON(기본)=LS 구독 종목 호가를 ls_ws_orderbook 에 쌓음(영구만이 아님). " + "옵투나 호가 3차. tick 모드=체결 1건당 1장. " + "LS_WS_TICK_SAVE OFF 여도 이 스위치만 ON 이면 호가는 쌓임. " + "위 UH1 구독이 OFF 면 쌓을 사진이 없음" + )), + _f("LS_GAP_FILL_CANDIDATES", "⛔ 후보 LS 갭 REST (t8412)", "bool", default=False, + hint=( + "OFF(기본)=폴백 구독만, 후보 전 종목 t8412 금지. " + "영구 갭은 LS_GAP_FILL_ENABLED" + )), _f("PERMANENT_SUBSCRIBE_ENABLED", "📡 영구구독 마스터 (재시작 필요)", "bool", default=True, hint=( "ON(기본)=테이블 enabled 종목을 KR=LS WS / US=해외 WS 로 구독. " @@ -246,7 +313,7 @@ def build_live_config_groups() -> List[GroupDef]: _f("PERM_LS_FILL_SLEEP_MIN", "확정봉 가져오기 종목간 sleep 최소(초)", "float", default=1.0), _f("PERM_LS_FILL_SLEEP_MAX", "확정봉 가져오기 종목간 sleep 최대(초)", "float", default=3.0), _f("KIS_APP_KEY_OB_REAL", "🔑 KIS 실전 호가 전용 앱키 (선택)", "text", - hint="호가 전용 2번째 실전 앱키(시세 키와 역할 분리). 미입력 시 메인 세션+SAVE 플래그 현행. spill 틱용 아님."), + hint="호가 전용 2번째 실전 앱키. 미입력 시 메인에 호가 TR 안 붙임(합산 금지). spill 틱용 아님."), _f("KIS_APP_SECRET_OB_REAL", "🔑 KIS 실전 호가 전용 시크릿 (선택)", "text", hint="호가 전용 2번째 실전 시크릿키."), _f("KIS_APP_KEY_OB_MOCK", "🔑 KIS 모의 호가 전용 앱키 (선택)", "text", @@ -303,10 +370,39 @@ def build_live_config_groups() -> List[GroupDef]: "float", default=0.0, hint=( - "0(기본)=마지막 RAM 호가로 검사(만료로 건너뛰지 않음) | " - "양수=그 초보다 오래면 스냅 없음으로 취급. REST 없음" + "0(기본)=마지막 호가로 검사하되, 벤더가 폴백 나이 넘으면 그 사진만 버리고 2차·3차를 봄. " + "만료=None 으로 필터 ON 통과 구멍을 만들지 않음. REST 없음" ), ), + _f( + "WS_PRICE_MAX_AGE_SEC", + "⏱ WS 현재가 조회 나이(초)", + "float", + default=0.0, + hint=( + "0(기본)=읽기 폴백 나이(LIVE_FEED_FALLBACK, 기본 3초)로 메인→2차→3차. " + "양수=그 초와 폴백 나이 중 짧은 쪽. None 인자만 마지막 RAM. " + "매도 4차 REST 는 1·2·3 실패 시에만(키움 ka10007, 쿨다운)" + ), + ), + _f( + "SELL_WS_STALE_REST_SEC", + "⏱ 매도 죽은선 REST(초)", + "float", + default=1.0, + hint=( + "1·2·3차 WS 가 모두 없거나 2초 넘을 때 키움 ka10007 1회. " + "한투 inquire_price(60초 캐시) 안 씀. 0=REST 안 함. " + "종목당 쿨다운은 아래 간격. 매수 루프 전 종목 REST 금지" + ), + ), + _f( + "SELL_WS_STALE_REST_COOLDOWN_SEC", + "⏱ 매도 REST 확인 간격(초)", + "float", + default=30.0, + hint="죽은선 REST를 종목당 이 초마다 1회. 유량 보호. 코드 기본 30", + ), _f( "WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY", "⛔ 호가 사진 없으면 매수 안 함", @@ -888,8 +984,8 @@ def build_live_config_groups() -> List[GroupDef]: "bool", default=True, hint=( - "ON(권장) = 취소 요청이 실패해도 실제 잔고를 보니 0주면 지정가에 팔린 걸로 확정 | " - "→ 유령 포지션(DB엔 있는데 실제론 없는 것) 방지" + "ON(권장) = 취소가 실패해도 보유=0·매도가능=0·시장 미체결 매도=0 일 때만 지정가 체결로 확정 | " + "매도가능만 0인 경우는 아직 안 팔린 것 → 확정 안 함" ), ), _f( @@ -902,6 +998,32 @@ def build_live_config_groups() -> List[GroupDef]: "→ '팔리는 중인데 유령이라고 지워버리는' 사고 방지" ), ), + _f( + "INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST", + "📋 유령정리 전 정정취소가능 조회", + "bool", + default=True, + hint=( + "ON(권장) = 실전은 한투 정정취소가능주문조회, 모의는 당일체결 미체결로 " + "시장에 남은 매도를 본 뒤에만 유령 정리 | " + "매도주문 나가면 매도가능=0 이 정상. 그걸 0주=없다 로 지우지 않음 | " + "OFF = 예전처럼 잔고 맵만 봄 (비권장)" + ), + ), + _f( + "PSBL_RVSECNCL_CACHE_TTL_SEC", + "📋 정정취소가능 조회 캐시(초)", + "int", + default=5, + hint="같은 초 안에 종목마다 REST 연타 방지. 잔고 캐시 TTL 과 비슷하게.", + ), + _f( + "PSBL_RVSECNCL_MAX_PAGES", + "📋 정정취소가능 최대 페이지", + "int", + default=5, + hint="한 페이지 최대 50건. 5페이지면 250건. REST 유량 보호 상한.", + ), _f( "BACKTEST_SLIP_PCT", "📊 백테스트 슬리피지 (백테 전용)", @@ -1098,9 +1220,13 @@ def build_live_config_groups() -> List[GroupDef]: "유니버스/봉 분슬롯과 무관(임계값만)"), _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False, hint="ON=WS 현재가 갱신마다 매도검사(백테 틱청산에 근접) · OFF=루프(~0.1초)만 · " - "전략별 *_TICK_SELL_ENABLED 가 비어있지 않으면 그쪽 우선 · 기본 OFF"), + "전략별 *_TICK_SELL_ENABLED 가 비어있지 않으면 그쪽 우선 · 기본 OFF. " + "주문은 TICK_SELL_ASYNC_PLACE(기본 ON) 이면 워커"), _f("TICK_SELL_MIN_INTERVAL_MS", "틱매도 최소간격(ms)", "int", default=50, hint="같은 종목 연속 틱 폭주 시 매도검사 하한 · 백테 poll≈50~100ms 정합"), + _f("TICK_SELL_ASYNC_PLACE", "틱매도 주문 비동기", "bool", default=True, + hint="ON(기본)=틱 수신 스레드에서는 신호만 큐. 잔고·주문 REST는 워커. " + "OFF=예전처럼 틱 콜백에서 place()까지 동기(시세 일시정지)"), ], }) @@ -1136,7 +1262,8 @@ def build_live_config_groups() -> List[GroupDef]: "title": "전략별 후보 상한 (WS 100 구독 분배)", "hint": ( "env_config · 키움 WS 실시간 구독은 계좌당 100종목이 한도. " - "전략별 후보 상한 합 + 영구구독 + 보유 여유 ≤ 100 이 되도록 분배. " + "전략별 후보 상한 합 + 보유 + grace ≤ 100. " + "영구구독 KR은 LS(키움 100·한투 41에 안 넣음). " "UPDOW 는 추가로 watchlist(UPDOWN_WATCH_MAX) 안에서만 유지됨." ), "fields": cand_fields, @@ -1363,6 +1490,8 @@ def build_live_config_groups() -> List[GroupDef]: hint="미등록 봇고아 일괄 시장가매도 시 REST 429 방지 · 종목마다 sleep"), _f("ORPHAN_RECONCILE_ENABLED", "장마감 고아복구", "bool", default=True, hint="매일 15:36~16:00 1회 · 실계좌 잔고↔active_trades 대조 · orders BUY 있는 미기록분만 복구 · 수동·MANUAL_HOLD 제외"), + _f("ORPHAN_RECONCILE_ON_STARTUP", "기동 시 잔고=DB 맞춤", "bool", default=True, + hint="봇 시작 직후 1회 · 매도 없음 · 고아복구+유령삭제+수량/평단 sync · 전략 쓰레드보다 먼저 · OFF 시 웹 「잔고=DB 맞추기」만"), _f("ORPHAN_RECONCILE_PRE_EOD_ENABLED", "Pre-EOD 고아복구", "bool", default=True, hint="활성 전략 중 가장 이른 EOD − N분 1회 · EOD 청산 전 active_trades 반영"), _f("ORPHAN_RECONCILE_PRE_EOD_LEAD_MIN", "Pre-EOD 선행(분)", "int", default=7, @@ -1374,7 +1503,7 @@ def build_live_config_groups() -> List[GroupDef]: _f("ORPHAN_PURGE_ACTIVE_AFTER_SELL_HISTORY", "청산후 유령 active 정리", "bool", default=True, hint="ON=active에 남았는데 매수 이후 청산 히스토리면 DB 행 삭제"), _f("GHOST_PURGE_ON_RECONCILE", "고아복구 시 유령잔고 삭제", "bool", default=True, - hint="ON=브로커 0주인데 active_trades 남은 종목 삭제(수동보호 제외) · Pre/Post EOD 동일 잔고조회에서 처리"), + hint="ON=브로커 보유 0·매도가능 0·정정취소가능 매도 없을 때만 active_trades 삭제(수동보호 제외) · Pre/Post EOD"), _f( "GHOST_PURGE_RECORD_HISTORY", "유령정리 시 거래내역 기록", diff --git a/kis_trader/ws/candle_series.py b/kis_trader/ws/candle_series.py index a6ea111..818c2d7 100644 --- a/kis_trader/ws/candle_series.py +++ b/kis_trader/ws/candle_series.py @@ -2,12 +2,12 @@ ws_candles 시리즈 — source=증권사, channel=ws|rest|rollup. 저장은 섞지 않음: 키움 REST 갭보정은 항상 source=kiwoom, channel=rest. -읽기(실매=Optuna): 틱 메인 WS 우선, 구멍만 kiwoom+rest. +읽기(실매=Optuna): 메인 WS → 2차 WS → LS WS → kiwoom+rest → rollup. """ from __future__ import annotations import os -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from kis_trader.utils.env import get_env_from_db @@ -67,20 +67,16 @@ def _provider_and_override() -> Tuple[str, str]: def live_read_pairs() -> Tuple[ReadPair, ...]: """실매·Optuna 기본 읽기 순서. - 틱 메인 WS 한 칸, 없으면 키움 REST 구멍. + 메인 WS → 2차 WS → LS WS(있으면) → 키움 REST 구멍 → 롤업. CANDLE_SOURCE=kis|kiwoom 이면 그 메인을 씀. 빈값이면 LIVE_TICK_PROVIDER. """ provider, override = _provider_and_override() main = override or provider - # 메인 WS → 구멍 키움 REST → 1M 롤업(상위 TF 웜업). 증권사 행은 섞지 않음. - if main == "kis": - return ( - ("kis", CHANNEL_WS), - ("kiwoom", CHANNEL_REST), - ("kiwoom", CHANNEL_ROLLUP), - ) + aux = "kis" if main == "kiwoom" else "kiwoom" return ( - ("kiwoom", CHANNEL_WS), + (main, CHANNEL_WS), + (aux, CHANNEL_WS), + ("ls", CHANNEL_WS), ("kiwoom", CHANNEL_REST), ("kiwoom", CHANNEL_ROLLUP), ) @@ -89,19 +85,30 @@ def live_read_pairs() -> Tuple[ReadPair, ...]: def live_read_label() -> str: provider, override = _provider_and_override() main = override or provider + aux = "kis" if main == "kiwoom" else "kiwoom" if override: - return f"{override.upper()}+kiwoom_rest" - return f"LIVE({main})+kiwoom_rest" + return f"{override.upper()}+{aux}+ls+kiwoom_rest" + return f"LIVE({main})+{aux}+ls+kiwoom_rest" def dedupe_by_read_pairs( rows: Sequence[Dict[str, Any]], pairs: Optional[Sequence[ReadPair]] = None, + *, + ticks: Optional[Sequence[Dict[str, Any]]] = None, + tf_min: int = 1, + missing_policy: str = "hole", + live_cover: bool = False, ) -> List[Dict[str, Any]]: - """candle_time 1행 — pairs 앞쪽이 이김. source/channel 은 결과에 남기지 않음(레거시 호환).""" + """candle_time 1행 — pairs 앞쪽이 이김. source/channel 은 결과에 남기지 않음. + + ticks 가 있으면 WS 봉만 쓰레기(0건·봉끝 나이초과)일 때 다음 쌍. 한 봉 혼합 없음. + live_cover=True 이면 링이 그 분을 커버 못할 때 그 WS 봉을 유지. + """ + from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage + order = tuple(pairs or live_read_pairs()) - rank = {p: i for i, p in enumerate(order)} - seen: Dict[str, Tuple[int, Dict[str, Any]]] = {} + by_ct: Dict[str, Dict[ReadPair, Dict[str, Any]]] = {} for row in rows: ct = str(row.get("candle_time") or "")[:12] if not ct: @@ -110,14 +117,54 @@ def dedupe_by_read_pairs( str(row.get("source") or ""), str(row.get("channel") or ""), ) - pri = rank.get((src, ch), 999) - prev = seen.get(ct) - if prev is None or pri < prev[0]: - seen[ct] = (pri, dict(row)) + by_ct.setdefault(ct, {}) + pair = (src, ch) + if pair not in by_ct[ct]: + by_ct[ct][pair] = dict(row) + + tf = max(1, int(tf_min or 1)) out: List[Dict[str, Any]] = [] - for ct in sorted(seen.keys()): - d = seen[ct][1] - d.pop("source", None) - d.pop("channel", None) - out.append(d) + for ct in sorted(by_ct.keys()): + picked: Optional[Dict[str, Any]] = None + for pair in order: + bar = by_ct[ct].get(pair) + if bar is None: + continue + src, ch = pair + if ticks is not None and ch == CHANNEL_WS: + if live_cover: + bad = live_bar_is_garbage( + ticks, candle_time=ct, tf_min=tf, source=src, + ) + else: + bad = bar_is_garbage( + ticks, candle_time=ct, tf_min=tf, source=src, + missing_policy=missing_policy, + ) + if bad: + continue + picked = dict(bar) + break + if picked is None: + continue + picked.pop("source", None) + picked.pop("channel", None) + out.append(picked) return out + + +def pick_bar_by_read_pairs( + rows: Sequence[Dict[str, Any]], + pairs: Optional[Sequence[ReadPair]] = None, + *, + ticks: Optional[Sequence[Dict[str, Any]]] = None, + tf_min: int = 1, + missing_policy: str = "hole", + live_cover: bool = False, +) -> Optional[Dict[str, Any]]: + """한 candle_time 묶음에서 승자 봉 1개. 없으면 None.""" + out = dedupe_by_read_pairs( + rows, pairs, ticks=ticks, tf_min=tf_min, + missing_policy=missing_policy, live_cover=live_cover, + ) + return out[0] if out else None diff --git a/kis_trader/ws/kis_ws.py b/kis_trader/ws/kis_ws.py index 5b6d4eb..02fba9c 100644 --- a/kis_trader/ws/kis_ws.py +++ b/kis_trader/ws/kis_ws.py @@ -2,7 +2,8 @@ kis_trader/ws/kis_ws.py — KIS WebSocket 실시간 체결가 캐시 (H0STCNT0) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 역할: check_sell_signals() 의 inquire_price() REST 폴링을 대체. - 보유 종목 코드를 구독해두면 KIS가 체결마다 push → 즉시 가격 캐시 갱신. + WSManager가 구독한 종목(기본=후보·보유, MINIMAL=보유만) 체결 push → RAM 캐시. + 영구구독 KR은 LS. 세션 41은 영구 전용 슬롯이 아님. WebSocket vs REST: REST : 요청마다 0.5s 딜레이(API 제한) + 응답 대기 → 보유 3종목 ≈ 3초 낭비 @@ -12,12 +13,13 @@ KIS 공식 스펙: TR_ID : H0STCNT0 (국내주식 실시간체결가) 실전 URL : ws://ops.koreainvestment.com:21000 (KIS_WS_URL_REAL, env/DB 변경 가능) 모의 URL : ws://ops.koreainvestment.com:31000 (KIS_WS_URL_MOCK, env/DB 변경 가능) - 세션 구독 한도: 최대 41종목 (MAX_STOCKS=3~4 수준에서 문제 없음) + 세션 구독 한도: 최대 41종목 (H0STCNT0). 누가 들어가는지는 WSManager 라우팅. KIS 2026-02-24 경고 준수 (무한 재연결 차단 정책): - 재연결 횟수 제한: 1시간 내 MAX_RECONNECTS_PER_HOUR 회 초과 시 자동 대기 - 최대 총 재연결 횟수: MAX_RECONNECT_ATTEMPTS 회 초과 시 WebSocket 종료 → REST fallback - 지수 백오프: 5초 → 10초 → 20초 ... 최대 300초 + - 정상 종료/세션 양보: 연결 종료 전 구독 해제(tr_type=2). 소켓만 닫지 않음. 모의투자(VTS): KIS 모의투자는 WebSocket을 지원하지 않는 경우가 많음. @@ -37,7 +39,7 @@ import random import threading import time from pathlib import Path -from typing import Any, Dict, Optional, Set +from typing import Any, Dict, List, Optional, Set import requests @@ -100,7 +102,11 @@ class KISWebSocketPriceCache: IDX_BIDP1 = 11 # BIDP1: 매수호가1 IDX_CNTG_VOL = 12 # CNTG_VOL: 체결 거래량 (틱당, TickRecorder용) IDX_ACML_VOL = 13 # ACML_VOL: 누적 거래량 (봉 델타용) + IDX_CTTR = 18 # CTTR: 체결강도 (ccnl_krx 공식 컬럼) + IDX_BSOP_DATE = 33 # BSOP_DATE: 영업일자 YYYYMMDD IDX_VOLUME = 12 # 하위호환 alias → CNTG_VOL + # ccnl_krx 공식 컬럼 수 (MKSC_SHRN_ISCD … VI_STND_PRC). 한 프레임 N건이면 이 폭으로 자른다. + H0STCNT0_N_FIELDS = 46 # 재연결 정책 (KIS 2026-02-24 경고 준수) MAX_RECONNECT_ATTEMPTS = 10 # 총 재연결 최대 횟수 (STABLE_CONN_RESET_SEC 이상 안정 연결 후 끊기면 초기화) @@ -114,10 +120,13 @@ class KISWebSocketPriceCache: # Approval key 유효시간 (23시간 캐시). KIS: access_token 갱신주기 6시간·유효 24시간. APPROVAL_KEY_CACHE_SEC = 82800 - def __init__(self, app_key: str, app_secret: str, is_mock: bool = True): + def __init__(self, app_key: str, app_secret: str, is_mock: bool = True, + approval_slot: str = "main"): self.app_key = app_key self.app_secret = app_secret self.is_mock = is_mock + slot = str(approval_slot or "main").strip().lower() + self.approval_slot = slot if slot in ("main", "ob") else "main" # WebSocket URL (env/DB 로 재정의 가능 → 연결 실패 시 사용자가 수정) _default_real = "ws://ops.koreainvestment.com:21000" @@ -275,27 +284,59 @@ class KISWebSocketPriceCache: ) return True - def stop(self, clear_subscriptions: bool = False) -> None: + def _unsubscribe_all_before_close(self, reason: str = "", *, clear_ram: bool = False) -> int: + """세션 close 전 서버 구독 전부 해제 (영구종목 포함). + + RAM 목록은 ``clear_ram=False`` 면 유지 — 장 양보 후 재접속 시 _on_open 이 + 다시 구독한다. stop() 만 RAM 을 비운다. + """ + if not self._ws: + if clear_ram: + with self._sub_lock: + self._subscribed.clear() + with self._cache_lock: + self._cache.clear() + return 0 + with self._sub_lock: + codes = sorted(self._subscribed) + if clear_ram: + self._subscribed.clear() + if clear_ram: + with self._cache_lock: + self._cache.clear() + if not codes: + return 0 + role = (self._ws_role or "full").strip().lower() + gap = max(0.0, float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MIN_SEC", 0.08))) + n = 0 + for i, code in enumerate(codes): + if i > 0 and gap > 0: + time.sleep(gap) + if role in ("full", "tick"): + self._send_sub_msg(code, subscribe=False, tr_id="H0STCNT0") + if role == "orderbook" or ( + role == "full" and get_env_bool("WS_ORDERBOOK_SAVE_KIS", False) + ): + self._send_sub_msg(code, subscribe=False, tr_id="H0STASP0") + n += 1 + if gap > 0: + time.sleep(gap) + logger.info( + "✅ 세션 종료 전 구독 해제 %d종목 role=%s (%s)", + n, role, reason or "-", + ) + return n + + def stop(self, clear_subscriptions: bool = True) -> None: """ WebSocket 수신 중단 및 스레드 종료. - - Args: - clear_subscriptions: True 시 모든 종목 구독 해제 후 종료 - (봇 정상 종료 시 KIS 서버 정리용) + 종료 전 서버 구독 해제(한투 정상 케이스). ``clear_subscriptions`` 는 + 호환용이며 False 여도 해제는 한다. """ - # 옵션: 모든 구독 해제 (KIS 서버 정리용) - if clear_subscriptions and self._connected and self._ws: - with self._sub_lock: - codes = list(self._subscribed) - for code in codes: - self._send_sub_msg(code, subscribe=False) - with self._sub_lock: - self._subscribed.discard(code) - with self._cache_lock: - self._cache.pop(code, None) - logger.info("📡 WebSocket 구독 해제: %s", code) - logger.info("✅ 모든 WebSocket 구독 정리 완료 (%d종목)", len(codes)) - + try: + self._unsubscribe_all_before_close(reason="stop", clear_ram=True) + except Exception as e: + logger.warning("종료 전 구독 해제 실패: %s", e) self._running = False self._connected = False if self._ws: @@ -415,10 +456,11 @@ class KISWebSocketPriceCache: except Exception as ex: logger.debug("price_listener 예외 %s: %s", code, ex) - def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[Dict]: + def get_price(self, code: str, max_age_sec: Optional[float] = None) -> Optional[Dict]: """ WebSocket 캐시에서 실시간 가격 + 당일 시고저를 꺼냅니다. - max_age_sec 초 이내 수신된 체결 틱만 유효하게 취급합니다. + max_age_sec 초 이내 수신된 체결 틱만 유효. + max_age_sec is None 이면 나이 무시(마지막 RAM — 실매 매수·매도). 체결 공백 ≠ 가격 삭제. 반환 형식: { "stck_prpr": "73900", # 현재가 (체결가, REST inquire_price와 동일) @@ -440,10 +482,15 @@ class KISWebSocketPriceCache: if not entry: return None age = time.time() - entry.get("ts", 0) - if age > max_age_sec: + if max_age_sec is not None and age > max_age_sec: # 데이터가 너무 오래됨 → REST로 재확인 return None - return entry.get("data") + data = entry.get("data") + if isinstance(data, dict): + out = dict(data) + out["_age_ms"] = int(age * 1000) + return out + return data def get_orderbook(self, code: str, max_age_sec: float = 3.0) -> Optional[dict]: """메모리에 저장된 최신 KIS 호가를 반환합니다.""" @@ -540,7 +587,7 @@ class KISWebSocketPriceCache: logger.error("kis_approval_manager import 실패: %s", exc) return None - mgr = KISApprovalManager.instance(self.is_mock) + mgr = KISApprovalManager.instance(self.is_mock, slot=self.approval_slot) key = mgr.get_approval_key( self.app_key, self.app_secret, @@ -568,7 +615,7 @@ class KISWebSocketPriceCache: except ImportError as exc: logger.error("kis_approval_manager import 실패: %s", exc) return None - mgr = KISApprovalManager.instance(self.is_mock) + mgr = KISApprovalManager.instance(self.is_mock, slot=self.approval_slot) key = mgr.emergency_reissue( self.app_key, self.app_secret, @@ -599,7 +646,7 @@ class KISWebSocketPriceCache: old_key = (self._approval_key or "")[:8] try: from kis_approval_manager import KISApprovalManager - mgr = KISApprovalManager.instance(self.is_mock) + mgr = KISApprovalManager.instance(self.is_mock, slot=self.approval_slot) reloaded = mgr.reload_from_file() if reloaded and reloaded != (self._approval_key or ""): self._approval_key = reloaded @@ -633,6 +680,12 @@ class KISWebSocketPriceCache: reason=f"invalid approval streak={self._invalid_approval_streak} {err_line}", ) if new_key and self._ws: + try: + self._unsubscribe_all_before_close( + reason="invalid approval 재발급", clear_ram=False, + ) + except Exception as e: + logger.warning("invalid approval 전 구독 해제 실패: %s", e) try: self._ws.close() except Exception: @@ -762,12 +815,44 @@ class KISWebSocketPriceCache: logger.debug("H0STCNT0 암호화 데이터 수신 (처리 스킵) → REST fallback 권장") return - # 한 메시지에 여러 건이 포함될 수 있음 (parts[2] = 건수) - # 단순히 parts[3] 전체를 파싱 (단건 기준) - fields = parts[3].split("^") + # 한 메시지에 여러 건이 포함될 수 있음 (parts[2] = 건수). + # 공식: 0|H0STCNT0|004|체결1^…^체결2^… → 4건 전부 RAM·봉·ws_ticks 에 넣는다. + for fields in self._h0stcnt0_record_slices(parts[2], parts[3]): + self._apply_h0stcnt0_one(fields) + + def _h0stcnt0_record_slices(self, count_raw: str, data: str) -> List[list]: + """H0STCNT0 페이로드를 체결 1건씩 자른다. 폭이 안 맞으면 기존처럼 1건만.""" + tokens = (data or "").split("^") + min_need = max(self.IDX_PRICE, self.IDX_CHGPCT) + if len(tokens) <= min_need: + return [] + try: + n = int(str(count_raw or "1").strip() or "1") + except (TypeError, ValueError): + n = 1 + n = max(1, n) + width = int(self.H0STCNT0_N_FIELDS) + if n > 1: + if len(tokens) >= n * width: + pass + elif len(tokens) >= n and (len(tokens) % n) == 0: + width = len(tokens) // n + else: + n = 1 + if n <= 1: + return [tokens] + out: List[list] = [] + for i in range(n): + sl = tokens[i * width:(i + 1) * width] + if len(sl) <= min_need: + break + out.append(sl) + return out or [tokens] + + def _apply_h0stcnt0_one(self, fields: list) -> None: + """H0STCNT0 체결 1건 → RAM 현재가 + 봉 + TickRecorder.""" if len(fields) <= max(self.IDX_PRICE, self.IDX_CHGPCT): return - try: code = fields[self.IDX_CODE].strip() price = float(fields[self.IDX_PRICE]) @@ -781,6 +866,34 @@ class KISWebSocketPriceCache: high_raw = fields[self.IDX_HIGH] if len(fields) > self.IDX_HIGH else "0" low_raw = fields[self.IDX_LOW] if len(fields) > self.IDX_LOW else "0" + # 체결시간·체결강도 (H0STCNT0 ccnl_krx — 주는 그대로 보존) + cntg_hour_raw = ( + str(fields[self.IDX_TIME]).strip() + if len(fields) > self.IDX_TIME else "" + ) + bsop_date_raw = ( + str(fields[self.IDX_BSOP_DATE]).strip() + if len(fields) > self.IDX_BSOP_DATE else "" + ) + cntr_str_val: Optional[float] = None + if len(fields) > self.IDX_CTTR: + try: + cntr_str_val = float( + str(fields[self.IDX_CTTR]).strip().replace(",", "") or "0" + ) + except (ValueError, TypeError): + cntr_str_val = None + tick_time_raw: Optional[str] = None + if bsop_date_raw and cntg_hour_raw: + tick_time_raw = f"{bsop_date_raw}{cntg_hour_raw}" + elif cntg_hour_raw: + tick_time_raw = cntg_hour_raw + # tick_time: 영업일+체결시각(초까지) 우선 — 없으면 HHMMSS만 + if bsop_date_raw and cntg_hour_raw: + tick_time = f"{bsop_date_raw}{cntg_hour_raw}" + else: + tick_time = cntg_hour_raw + # inquire_price output 딕셔너리와 키 이름을 맞춤 # stck_oprc/hgpr/lwpr 도 함께 저장 → kis_short_ver2 매수 체크에서 REST 없이 활용 data_compat = { @@ -791,13 +904,30 @@ class KISWebSocketPriceCache: "stck_hgpr": high_raw, # 당일 고가 "stck_lwpr": low_raw, # 당일 저가 } + if cntr_str_val is not None: + data_compat["cntr_str"] = str(cntr_str_val) + if tick_time_raw: + data_compat["kis_cntg_hour_raw"] = cntg_hour_raw + data_compat["kis_bsop_date_raw"] = bsop_date_raw - with self._cache_lock: - self._cache[code] = {"data": data_compat, "ts": time.time()} - self._emit_price_listeners(code, float(price), data_compat) + # 읽기 RAM: 체결시각 vs 지금(2초). 적재(TickRecorder)는 버리지 않음. + skip_ram = False + try: + from kis_trader.engine.feed_fallback import ( + is_feed_read_stale, + packet_lag_seconds, + ) + lag = packet_lag_seconds(tick_time_raw or tick_time) + skip_ram = is_feed_read_stale(lag) + except Exception: + skip_ram = False - # 체결시간·체결량·누적량 (MCP: CNTG_VOL=12, ACML_VOL=13) - tick_time = fields[self.IDX_TIME].strip() if len(fields) > self.IDX_TIME else "" + if not skip_ram: + with self._cache_lock: + self._cache[code] = {"data": data_compat, "ts": time.time()} + self._emit_price_listeners(code, float(price), data_compat) + + # 체결량·누적량 (MCP: CNTG_VOL=12, ACML_VOL=13) cntg_vol = 0 acml_vol = 0 if len(fields) > self.IDX_CNTG_VOL: @@ -812,7 +942,7 @@ class KISWebSocketPriceCache: acml_vol = 0 # ── CandleAggregator: 키움증분 코드는 CNTG, KIS 전용은 ACML 델타 - if self._candle_agg is not None: + if self._candle_agg is not None and not skip_ram: use_inc = False try: use_inc = bool(self._candle_agg._volume_is_incremental(code)) @@ -826,6 +956,8 @@ class KISWebSocketPriceCache: try: self._tick_recorder.on_tick( code, price, cntg_vol, tick_time, source="kis", + cntr_str=cntr_str_val, + tick_time_raw=tick_time_raw, ) except Exception as ex: logger.debug("H0STCNT0→TickRecorder 실패 %s: %s", code, ex) @@ -833,7 +965,7 @@ class KISWebSocketPriceCache: logger.debug("H0STCNT0 수신: %s → %s원", code, int(price)) except (ValueError, IndexError) as e: - logger.debug("H0STCNT0 파싱 오류: %s | raw=%s", e, raw[:80]) + logger.debug("H0STCNT0 파싱 오류: %s", e) # ================================================================== # WebSocket 루프 (백그라운드 스레드) @@ -861,10 +993,14 @@ class KISWebSocketPriceCache: return float(seconds_until_kr_ws_open()) def _force_close_socket(self, reason: str) -> None: - """run_forever 점유 중에도 세션 양보 — approval 1:1 준수.""" + """run_forever 점유 중에도 세션 양보 — 구독 해제 후 close (approval 1:1).""" ws = self._ws if ws is None: return + try: + self._unsubscribe_all_before_close(reason=reason, clear_ram=False) + except Exception as exc: + logger.warning("세션 양보 전 구독 해제 실패: %s", exc) try: logger.info("🔌 [국내WS] 세션 양보 close (%s)", reason) ws.close() @@ -1239,6 +1375,8 @@ class CandleAggregator: self._peak_provider: Optional[Any] = None # 키움 WS 틱 체결량(FID 15) → 분봉 volume 합산. 미등록 종목은 KIS 누적거래량 델타. self._incremental_vol_codes: Set[str] = set() + self._tick_lookup: Optional[Any] = None + self._ls_bars_fn: Optional[Any] = None # 핫패스(락 안)에서 get_env 금지 — env 스냅샷 cold(수 초)가 락을 잡으면 # 전 전략 get_candles 가 멈춘다. db_writer 가 주기 갱신. self._freeze_on_confirm_cached: bool = bool( @@ -1266,6 +1404,14 @@ class CandleAggregator: else: self._incremental_vol_codes = {str(c).strip() for c in codes if c} + def set_tick_lookup(self, fn: Optional[Any]) -> None: + """실매 쓰레기 봉 판정용 TickRecorder 조회. fn(code) -> list[dict].""" + self._tick_lookup = fn + + def set_ls_bars_fn(self, fn: Optional[Any]) -> None: + """3차 LS 확정봉 RAM. fn(code, tf) -> list[dict] source=ls.""" + self._ls_bars_fn = fn + def _volume_is_incremental(self, code: str) -> bool: return str(code).strip() in self._incremental_vol_codes @@ -1537,19 +1683,29 @@ class CandleAggregator: @staticmethod def _candle_key(tick_time_str: str, timeframe: int) -> str: """ - 틱 시각(HHMMSS)과 timeframe(분)으로 봉 시작 시각 키를 반환. - 날짜는 오늘 날짜를 사용 (장 중에만 동작하므로 안전). + 틱 시각과 timeframe(분)으로 봉 시작 시각 키를 반환. 반환 형식: YYYYMMDDHHMM (분 단위, 초 버림) 예) tick_time="091523", timeframe=3 → "202603020915" (09:15봉) + 한투는 YYYYMMDD+HHMMSS(14자리+)로 올 수 있음 — 앞 8자리를 시분으로 읽지 말 것. + 키움 FID20 는 HHMMSS(6자리). 날짜는 오늘. """ import datetime as _dt try: - hh = int(tick_time_str[0:2]) if len(tick_time_str) >= 6 else 0 - mm = int(tick_time_str[2:4]) if len(tick_time_str) >= 6 else 0 + raw = (tick_time_str or "").strip().replace(":", "").replace("-", "").replace(" ", "") + tf = int(timeframe) or 1 + if len(raw) >= 14 and raw[:14].isdigit(): + today = raw[:8] + hh = int(raw[8:10]) + mm = int(raw[10:12]) + elif len(raw) >= 6 and raw[:6].isdigit(): + hh = int(raw[0:2]) + mm = int(raw[2:4]) + today = _dt.date.today().strftime("%Y%m%d") + else: + return _dt.datetime.now().strftime("%Y%m%d%H%M") # timeframe 단위로 내림: 3분봉이면 09:17 → 09:15 - floored_mm = (mm // timeframe) * timeframe - today = _dt.date.today().strftime("%Y%m%d") + floored_mm = (mm // tf) * tf return f"{today}{hh:02d}{floored_mm:02d}" except Exception: import datetime as _dt @@ -2177,7 +2333,7 @@ class CandleAggregator: @staticmethod def _live_candle_source_order() -> tuple: - """봉 읽기 순서 — 메인 WS → kiwoom REST 구멍 → 롤업. + """봉 읽기 순서 — 메인 WS → 2차 WS → LS WS → kiwoom REST → 롤업. CANDLE_SOURCE=kis|kiwoom 이면 그 메인, 빈값이면 LIVE_TICK_PROVIDER. """ @@ -2232,28 +2388,51 @@ class CandleAggregator: """ items: list = [] for src, ch in self._live_candle_source_order(): + if src == "ls" and ch == "ws": + fn = getattr(self, "_ls_bars_fn", None) + if callable(fn): + try: + extra = fn(code, tf) or [] + except Exception: + extra = [] + for b in extra: + row = dict(b) + row["source"] = "ls" + row["channel"] = "ws" + items.append(row) + continue buf = self._confirmed.get(self._bar_key(code, tf, src, ch)) if buf: - items.extend(buf) # 참조 복사 (봉 dict는 생성 후 불변이므로 안전) + items.extend(buf) return items - @staticmethod - def _merge_from_raw(raw_items: list) -> list: - """락 밖에서 호출 — 중복 candle_time 제거 후 시간순 정렬. - 락 경합 없이 O(n log n) 정렬을 수행. - """ - seen: dict = {} - for bar in raw_items: - ct = str(bar.get("candle_time") or "")[:12] - if ct and ct not in seen: - seen[ct] = bar - return sorted(seen.values(), key=lambda x: str(x.get("candle_time") or "")) + def _merge_from_raw(self, raw_items: list, code: str = "", tf: int = 1) -> list: + """락 밖에서 호출 — 쓰레기 WS 봉 skip 후 시간순.""" + from kis_trader.ws.candle_series import dedupe_by_read_pairs, live_read_pairs + + ticks = None + live = False + lookup = getattr(self, "_tick_lookup", None) + if callable(lookup) and code: + try: + ticks = lookup(code) or [] + live = True + except Exception: + ticks = None + return dedupe_by_read_pairs( + raw_items, + live_read_pairs(), + ticks=ticks, + tf_min=int(tf or 1), + missing_policy="hole", + live_cover=bool(live), + ) def _merge_confirmed_all_sources(self, code: str, tf: int) -> list: """모든 소스의 확정봉을 candle_time 기준으로 병합·정렬. NOTE: 락 안에서 호출하는 레거시 경로. 신규 코드는 _copy_raw_for_merge + _merge_from_raw 사용. """ - return self._merge_from_raw(self._copy_raw_for_merge(code, tf)) + return self._merge_from_raw(self._copy_raw_for_merge(code, tf), code, tf) def get_latest_confirmed(self, code: str, tf: int, source: str = "") -> Optional[dict]: """ @@ -2267,7 +2446,7 @@ class CandleAggregator: return buf[-1] if buf else None with self._lock: raw = self._copy_raw_for_merge(code, tf) - merged = self._merge_from_raw(raw) + merged = self._merge_from_raw(raw, code, tf) return merged[-1] if merged else None def get_prev_confirmed(self, code: str, tf: int, source: str = "") -> Optional[dict]: @@ -2278,7 +2457,7 @@ class CandleAggregator: return buf[-2] if buf and len(buf) >= 2 else None with self._lock: raw = self._copy_raw_for_merge(code, tf) - merged = self._merge_from_raw(raw) + merged = self._merge_from_raw(raw, code, tf) return merged[-2] if len(merged) >= 2 else None def get_candles(self, code: str, tf: int, n: int = 10, source: str = "") -> list: @@ -2292,7 +2471,7 @@ class CandleAggregator: return list(buf[-n:]) with self._lock: raw = self._copy_raw_for_merge(code, tf) - merged = self._merge_from_raw(raw) + merged = self._merge_from_raw(raw, code, tf) return merged[-n:] def get_confirmed_count(self, code: str, tf: int, source: str = "") -> int: @@ -2304,7 +2483,7 @@ class CandleAggregator: return len(self._confirmed.get(self._bar_key(code, tf, source), [])) with self._lock: raw = self._copy_raw_for_merge(code, tf) - return len(self._merge_from_raw(raw)) + return len(self._merge_from_raw(raw, code, tf)) def get_current_candle(self, code: str, tf: int, source: str = "kis") -> Optional[dict]: """ @@ -2518,20 +2697,33 @@ def _invalidate_kiwoom_token_cached( def _get_kiwoom_creds(db) -> tuple: """ - DB env_config 최신 행에서 키움 앱키/시크릿 반환. - KIS_MOCK 설정에 따라 MOCK / REAL 키를 자동 선택. + env_auth_config 최신 행에서 키움 앱키/시크릿 반환. + + 시세 REST(갭보정 ka10080 · 매도 4차 ka10007)는 **매매 KIS_MOCK 과 분리**. + 기본 ``KIWOOM_WS_FORCE_REAL=true`` → 실키·api.kiwoom.com + (키움 WS·WSManager._get_kiwoom_credentials 와 동일). Returns: (app_key, app_secret, is_mock) — 키 없으면 (None, None, False) """ try: - row = db.conn.execute( - "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" - ).fetchone() - if not row: + r = db.get_auth_env() if hasattr(db, "get_auth_env") else {} + if not r: return None, None, False - r = dict(row) - is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes") + 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", "on") + if force_real: + is_mock = False + else: + kw_mock_raw = str(r.get("KIWOOM_MOCK") or get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower() + if kw_mock_raw in ("true", "1", "yes", "y", "on"): + is_mock = True + elif kw_mock_raw in ("false", "0", "no", "n", "off"): + is_mock = False + else: + is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes") if is_mock: key = str(r.get("KIWOOM_APP_KEY_MOCK", "") or "").strip() secret = str(r.get("KIWOOM_APP_SECRET_MOCK", "") or "").strip() @@ -2696,6 +2888,76 @@ def get_kiwoom_candles_df( return df +def fetch_kiwoom_cur_prc_ka10007( + code: str, + kiwoom_key: str, + kiwoom_secret: str, + is_mock: bool = False, +) -> float: + """키움 ka10007(시세표성정보) 현재가. 4차 REST 전용. + + ka10001(유통주식 워커)와 TR 분리. ka10080 과 같은 세마포어로 유량 공유. + 핫패스이므로 재시도는 짧게. 실패=0. + """ + code = str(code or "").strip() + if not code or not kiwoom_key or not kiwoom_secret: + return 0.0 + token = _get_kiwoom_token_cached(kiwoom_key, kiwoom_secret, is_mock) + if not token: + return 0.0 + domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com" + url = f"https://{domain}/api/dostk/mrkcond" + headers = { + "content-type": "application/json;charset=UTF-8", + "appkey": kiwoom_key, + "appsecret": kiwoom_secret, + "authorization": f"Bearer {token}", + "api-id": "ka10007", + "cont-yn": "N", + "next-key": "", + } + body = {"stk_cd": code} + sem = _ka10080_semaphore() + data: Dict[str, Any] = {} + http_st = 0 + sem.acquire() + try: + resp = requests.post(url, json=body, headers=headers, timeout=8) + http_st = int(resp.status_code) + try: + data = resp.json() if resp.content else {} + except Exception: + data = {} + except Exception as e: + logger.warning("키움 ka10007 조회 실패 %s: %s", code, e) + return 0.0 + finally: + sem.release() + rc_raw = (data or {}).get("return_code", 0) + msg = str((data or {}).get("return_msg") or "").strip() + if _ka10080_is_rate_limit(http_st, rc_raw, msg): + logger.warning( + "키움 ka10007 유량 %s http=%s rc=%s msg=%s — 재시도 안 함", + code, http_st, rc_raw, msg[:80], + ) + return 0.0 + try: + if int(rc_raw) != 0: + logger.debug("키움 ka10007 API오류 %s rc=%s %s", code, rc_raw, msg[:80]) + return 0.0 + except Exception: + return 0.0 + raw = data.get("cur_prc") if isinstance(data, dict) else None + if raw is None and isinstance(data, dict): + inner = data.get("output") or data.get("data") or {} + if isinstance(inner, dict): + raw = inner.get("cur_prc") + try: + return abs(float(str(raw or "0").replace(",", ""))) + except (TypeError, ValueError): + return 0.0 + + def fetch_kiwoom_stock_meta_detail( code: str, kiwoom_key: str, diff --git a/scripts/test_live_execution_validation.py b/scripts/test_live_execution_validation.py index ddcbfce..38b1ea4 100755 --- a/scripts/test_live_execution_validation.py +++ b/scripts/test_live_execution_validation.py @@ -2,7 +2,7 @@ """ scripts/test_live_execution_validation.py ========================================= -실매 코어 스모크 (주문·REST 연타 없음). +실매 코어 스모크 (주문·REST 연타·실WS 연결 없음). 반드시 **DB에서 읽은 실제 ON/OFF** 를 먼저 찍고, 그 값에 맞춰 기대 동작을 검사한다. 가상 호가 주입은 「DB에서 필터 ON인 전략」에만 실매와 동일하게 적용한다. @@ -12,10 +12,12 @@ scripts/test_live_execution_validation.py · ws_candles source/channel 정규화 · 실매=Optuna 읽기쌍 · 갭보정 키움 REST만 (KIS REST 강제 무시) · 영구구독 마스터 · LS DB 적재 가드 · 운영설정 UI 키 + · WS close 전 구독해제(한투/LS/키움) · freeze ON · 호가 TTL 분리 + · Optuna OHLC 폴백 강제 OFF · approval 공유 · SKIP_HTS DB 표기 -실행: +실행 (중요 코드 수정 후 완료 보고 전 최소 1회): python3 -u scripts/test_live_execution_validation.py - # 로그 예: logs/test_live_execution_validation_*.log + # 로그: logs/test_live_execution_validation_YYYYMMDD_HHMMSS.log """ from __future__ import annotations @@ -23,8 +25,9 @@ import logging import sys import threading import time +from datetime import datetime from pathlib import Path -from typing import Dict, List +from typing import Any, List from unittest.mock import MagicMock, patch ROOT = Path(__file__).resolve().parents[1] @@ -58,6 +61,53 @@ def _전략표시(sid: str) -> str: return f"{전략한글.get(sid, sid)}[{sid}]" +def _소스필수(실패목록: List[str], obj: Any, needle: str, 라벨: str) -> None: + """실소켓/REST 없이 소스 문자열만 검사.""" + import inspect + + try: + src = inspect.getsource(obj) + except (OSError, TypeError) as exc: + 실패목록.append(f"{라벨} getsource 실패") + _실패(f"{라벨}: getsource 실패 ({exc})") + return + if needle not in src: + 실패목록.append(f"{라벨} 누락") + _실패(f"{라벨}: `{needle[:72]}` 없음") + else: + _성공(라벨) + + +def _파일필수(실패목록: List[str], rel: str, needle: str, 라벨: str) -> None: + path = ROOT / rel + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + 실패목록.append(f"{라벨} 파일없음") + _실패(f"{라벨}: {rel} 읽기 실패 ({exc})") + return + if needle not in text: + 실패목록.append(f"{라벨} 누락") + _실패(f"{라벨}: `{needle[:72]}` 없음 ({rel})") + else: + _성공(라벨) + + +def _파일금지(실패목록: List[str], rel: str, needle: str, 라벨: str) -> None: + path = ROOT / rel + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + 실패목록.append(f"{라벨} 파일없음") + _실패(f"{라벨}: {rel} 읽기 실패 ({exc})") + return + if needle in text: + 실패목록.append(f"{라벨} 금지패턴") + _실패(f"{라벨}: `{needle[:72]}` 가 있으면 안 됨 ({rel})") + else: + _성공(라벨) + + def _db원문(키: str) -> str: from kis_trader.utils.env import get_env_from_db @@ -407,7 +457,7 @@ def run_validation() -> bool: 허브2._get_ls_ws = MagicMock(return_value=None) home2 = 허브2._subscribe_tick_prefer_or_spill("000660", "kiwoom") 라벨2 = 허브2.get_tick_feed_label("000660") - if home2 != "kis" or 라벨2 != "kis(spill)": + if home2 != "kis" or "kis" not in 라벨2 or "spill" not in 라벨2: 실패목록.append(f"키움→한투 spill 실패 {home2}/{라벨2}") _실패(f"키움 한도 spill 실패: {home2}/{라벨2}") else: @@ -415,12 +465,66 @@ def run_validation() -> bool: 주문 = OrderManager(client=MagicMock(), db=MagicMock()) 주문.tick_feed_label_provider = 허브2.get_tick_feed_label + 주문.ob_feed_label_provider = getattr(허브2, "get_ob_feed_label", None) mm줄 = 주문._tick_feed_mm_line("000660") - if "시세:" not in mm줄: + if "시세:" not in mm줄 or "호가:" not in mm줄: 실패목록.append(f"MM 시세줄 이상 {mm줄}") - _실패(f"매터모스트 시세 표기 이상: {mm줄}") + _실패(f"매터모스트 시세/호가 표기 이상: {mm줄}") else: - _성공(f"체결 알림 시세 한 줄 예: {mm줄}") + _성공(f"체결 알림 시세·호가 한 줄 예: {mm줄}") + + # 메인 2초 실패 → 2차 get_price (RAM 없음=실패, REST 없음) + 허브_fb = WSManager(db=_가짜DB(), kis_client=MagicMock()) + 키움_stale = MagicMock() + 키움_stale.get_price = MagicMock(return_value=None) + 한투_ok = MagicMock() + 한투_ok.get_price = MagicMock(return_value={"stck_prpr": "12345"}) + 허브_fb._kiwoom_ws = 키움_stale + 허브_fb.ws_cache = 한투_ok + 허브_fb._get_ls_ws = MagicMock(return_value=None) + px_fb = 허브_fb.get_price("005930") + lab_fb = 허브_fb.get_tick_feed_label("005930") + if not isinstance(px_fb, dict) or str(px_fb.get("stck_prpr") or "") != "12345": + 실패목록.append(f"get_price 2차 폴백 실패 {px_fb}") + _실패(f"메인 None 인데 2차 현재가를 안 씀: {px_fb}") + elif "kis" not in str(lab_fb): + 실패목록.append(f"2차 시세라벨 이상 {lab_fb}") + _실패(f"2차 폴백 라벨에 kis 없음: {lab_fb}") + else: + _성공(f"메인 실패→2차 시세 {px_fb.get('stck_prpr')} 라벨={lab_fb}") + + 허브_fb3 = WSManager(db=_가짜DB(), kis_client=MagicMock()) + 키움_n = MagicMock(); 키움_n.get_price = MagicMock(return_value=None) + 한투_n = MagicMock(); 한투_n.get_price = MagicMock(return_value=None) + 엘에스_ok = MagicMock(); 엘에스_ok.get_price = MagicMock(return_value={"stck_prpr": "777"}) + 허브_fb3._kiwoom_ws = 키움_n + 허브_fb3.ws_cache = 한투_n + 허브_fb3._get_ls_ws = MagicMock(return_value=엘에스_ok) + px3 = 허브_fb3.get_price("005930") + lab3 = 허브_fb3.get_tick_feed_label("005930") + if not isinstance(px3, dict) or str(px3.get("stck_prpr") or "") != "777": + 실패목록.append(f"get_price 3차 폴백 실패 {px3}") + _실패(f"1·2차 None 인데 3차 LS 를 안 씀: {px3}") + elif "ls" not in str(lab3): + 실패목록.append(f"3차 시세라벨 이상 {lab3}") + _실패(f"3차 폴백 라벨에 ls 없음: {lab3}") + else: + _성공(f"1·2차 실패→3차 LS {px3.get('stck_prpr')} 라벨={lab3}") + + from kis_trader.engine.feed_fallback import merge_ticks_time_axis_fallback + merged_empty = merge_ticks_time_axis_fallback( + [ + {"tick_time": "20260819120000", "source": "kiwoom", "price": 1, "_lag_sec": 9}, + {"tick_time": "20260819120000", "source": "kis", "price": 2, "_lag_sec": 8}, + ], + main_src="kiwoom", + max_lag_sec=2.0, + ) + if merged_empty: + 실패목록.append(f"늦은 보조를 씀 {merged_empty}") + _실패("메인·보조 둘 다 2초 초과인데 틱을 남김") + else: + _성공("메인·보조 둘 다 늦으면 그 초 틱 없음(OHLC 메우지 않음)") 허브3 = WSManager(db=_가짜DB(), kis_client=MagicMock()) 한투3 = MagicMock() @@ -456,6 +560,12 @@ def run_validation() -> bool: "PERM_LS_FILL_SLEEP_MIN", "PERM_LS_FILL_SLEEP_MAX", "WS_CANDLE_FREEZE_ON_CONFIRM", + "LIVE_FEED_FALLBACK_MAX_AGE_SEC", + "LS_FEED_FALLBACK_SUBSCRIBE", + "LS_GAP_FILL_CANDIDATES", + "LS_WS_TICK_SAVE", + "LS_WS_ALSO_HOGA", + "LS_WS_ORDERBOOK_SAVE", ): if 키 not in ENV_CONFIG_KEYS: 실패목록.append(f"ENV 누락 {키}") @@ -485,6 +595,13 @@ def run_validation() -> bool: "PERMANENT_SUBSCRIBE_ENABLED", "WS_GAP_FILL_KIS_FALLBACK", "PERM_LS_FILL_BARS", + "LIVE_FEED_FALLBACK_MAX_AGE_SEC", + "LS_FEED_FALLBACK_SUBSCRIBE", + "LS_WS_TICK_SAVE", + "LS_WS_ALSO_HOGA", + "LS_WS_ORDERBOOK_SAVE", + "CANDLE_GARBAGE_FALLBACK", + "WS_ORDERBOOK_SAVE_KIS", ): if need not in ui키들: 실패목록.append(f"UI 누락 {need}") @@ -529,6 +646,9 @@ def run_validation() -> bool: if pairs[0][1] != "ws" or ("kiwoom", "rest") not in pairs: 실패목록.append(f"읽기쌍 규칙 위반 {pairs}") _실패(f"메인은 WS, 구멍은 kiwoom+rest 여야 함: {pairs}") + elif ("ls", "ws") not in pairs or len(pairs) < 5: + 실패목록.append(f"읽기쌍 길이 {pairs}") + _실패(f"메인WS+2차WS+LS+rest+rollup 5쌍이어야 함: {pairs}") else: _성공("메인 channel=ws, 구멍에 kiwoom+rest 포함") @@ -611,6 +731,136 @@ def run_validation() -> bool: else: _성공("영구구독→LS sync 가 마스터 OFF 시 빈 집합") + if not hasattr(WSManager, "_sync_feed_fallback_to_ls"): + 실패목록.append("_sync_feed_fallback_to_ls 없음") + _실패("LS 3차 폴백 구독 owner=_feed_fallback 없음") + else: + fb_src = inspect.getsource(WSManager._sync_feed_fallback_to_ls) + if "LS_FEED_FALLBACK_SUBSCRIBE" not in fb_src: + 실패목록.append("LS_FEED_FALLBACK_SUBSCRIBE 미적용") + _실패("_sync_feed_fallback_to_ls 가 LS_FEED_FALLBACK_SUBSCRIBE 를 안 봄") + else: + _성공("LS 3차 폴백 구독 _sync_feed_fallback_to_ls") + + st_src = inspect.getsource(WSManager.sync_targets) + if "_sync_feed_fallback_to_ls" not in st_src or "_ls_ram_universe_codes" not in st_src: + 실패목록.append("sync_targets LS RAM 합집합 없음") + _실패("sync_targets 가 _sync_feed_fallback_to_ls(_ls_ram_universe_codes) 를 안 부름") + else: + _성공("sync_targets 끝에서 LS RAM 합집합 구독 (MINIMAL OFF 경로)") + + rec_src = inspect.getsource(WSManager._reconcile_split_subscriptions) + if "_sync_feed_fallback_to_ls" not in rec_src or "_ls_ram_universe_codes" not in rec_src: + 실패목록.append("split reconcile LS RAM 합집합 없음") + _실패("_reconcile_split_subscriptions 가 LS RAM 합집합을 안 부름") + else: + _성공("split reconcile 도 LS RAM 합집합") + + start_src = inspect.getsource(WSManager.start) + if "kis_ws_ob = self.ws_cache" in start_src: + 실패목록.append("한투 호가 메인 폴백") + _실패("WSManager.start 가 kis_ws_ob=메인 폴백(ASP0 합산)을 함") + elif "kis_ws_ob = None" not in start_src: + 실패목록.append("kis_ws_ob None 미설정") + _실패("2키 실패 시 kis_ws_ob=None 이 없음") + else: + _성공("한투 호가: 2키 전용, 메인 ASP0 폴백 없음") + + try_ob = inspect.getsource(WSManager._try_subscribe_vendor_ob) + if "ob is self.ws_cache" not in try_ob: + 실패목록.append("OB 구독 메인 거절 없음") + _실패("_try_subscribe_vendor_ob 가 메인=OB 를 거절하지 않음") + else: + _성공("KIS 호가 구독은 전용 세션만") + + from unittest.mock import patch as _patch + from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage + + _ct = "202608191000" + with _patch( + "kis_trader.engine.feed_fallback.candle_garbage_fallback_enabled", + return_value=True, + ), _patch( + "kis_trader.engine.feed_fallback.live_feed_fallback_max_age_sec", + return_value=3.0, + ): + if not bar_is_garbage([], candle_time=_ct, tf_min=1, source="kiwoom"): + 실패목록.append("쓰레기 0건 실패") + _실패("bar_is_garbage 틱 0건은 구멍이어야 함") + else: + _성공("분봉 쓰레기: 그 분 틱 0건 = 구멍") + + stale_in = [{ + "source": "kiwoom", + "tick_time": "20260819100000", + "tick_time_raw": "20260819100000", + }] + if not bar_is_garbage(stale_in, candle_time=_ct, tf_min=1, source="kiwoom"): + 실패목록.append("쓰레기 stale 실패") + _실패("bar_is_garbage 전부 봉끝 대비 나이초과면 구멍이어야 함") + else: + _성공("분봉 쓰레기: 전부 봉끝 대비 나이초과 = 구멍") + + fresh = [{ + "source": "kiwoom", + "tick_time": "20260819100058", + "tick_time_raw": "20260819100058", + }] + if bar_is_garbage(fresh, candle_time=_ct, tf_min=1, source="kiwoom"): + 실패목록.append("신선 봉을 쓰레기로") + _실패("봉끝 2초 전 틱은 유지여야 함") + else: + _성공("분봉 쓰레기: 봉끝 직전 틱이면 유지") + + other = [{ + "source": "kiwoom", + "tick_time": "20260819100000", + "tick_time_raw": "20260819100000", + }] + if live_bar_is_garbage(other, candle_time=_ct, tf_min=1, source="ls"): + 실패목록.append("LS 봉 오판") + _실패("실매 링에 LS 틱 없으면 LS 봉을 버리면 안 됨") + else: + _성공("실매 링: 해당 소스 틱 없으면 봉 유지") + + from kis_trader.ws.candle_series import pick_bar_by_read_pairs + kis_ok = [{ + "source": "kis", + "tick_time": "20260819100058", + "tick_time_raw": "20260819100058", + }] + picked = pick_bar_by_read_pairs( + [ + {"candle_time": _ct, "close": 1, "source": "kiwoom", "channel": "ws"}, + {"candle_time": _ct, "close": 2, "source": "kis", "channel": "ws"}, + ], + (("kiwoom", "ws"), ("kis", "ws")), + ticks=stale_in + kis_ok, tf_min=1, missing_policy="hole", live_cover=False, + ) + if not picked or int(picked.get("close") or 0) != 2: + 실패목록.append("pick 2차 실패") + _실패(f"메인 쓰레기면 2차 봉 통째: {picked}") + else: + _성공("pick_bar: 메인 쓰레기 → 2차 봉 통째") + + from kis_trader.ws.ls_ws import LSWebSocketPriceCache + ls_msg_src = inspect.getsource(LSWebSocketPriceCache._on_message) + if '_tick_recorder and get_env_bool("LS_WS_TICK_SAVE"' in ls_msg_src: + 실패목록.append("LS 틱콜백이 TICK_SAVE에 묶임") + _실패("LS_WS_TICK_SAVE OFF 가 호가 틱동기 콜백까지 끔") + elif "_tick_recorder" not in ls_msg_src: + 실패목록.append("LS _tick_recorder 없음") + _실패("LS _on_message 에 _tick_recorder 없음") + else: + _성공("LS 틱콜백은 TICK_SAVE와 분리(호가 적재 가능)") + + gap_src = inspect.getsource(WSManager._enqueue_ls_gap_fill) + if "LS_GAP_FILL_CANDIDATES" not in gap_src: + 실패목록.append("LS_GAP_FILL_CANDIDATES 가드 없음") + _실패("후보 LS 갭 REST 기본 OFF 가드 없음") + else: + _성공("후보 LS 갭 REST 는 LS_GAP_FILL_CANDIDATES 가드") + # 갭 집합에 perm 합집합 금지 (후보|보유만) recon_src = inspect.getsource(WSManager._reconcile_split_subscriptions) if "_gap_refill_codes = set(kis_want) | set(kw_want) | perm" in recon_src: @@ -693,6 +943,161 @@ def run_validation() -> bool: 실패목록.append(f"스키마 검증 오류: {exc}") _실패(f"ws_candles 스키마 검증 오류: {exc}") + # ── 12) WS close 전 구독해제 (실소켓 없음 · 소스만) ────────────── + print("\n[12단계] WS 종료·양보 전 구독해제 (한투 공문 정상 케이스)") + print(" 소켓만 close 금지. 실WS/approval REST 호출 없음.") + try: + from kis_trader.ws.kis_ws_overseas import KISOverseasWebSocket + from kis_trader.ws.kiwoom_ws import KiwoomWebSocketPriceCache + from kis_trader.ws.ls_ws import LSWebSocketPriceCache + + _소스필수( + 실패목록, KISWebSocketPriceCache.stop, + "_unsubscribe_all_before_close", "한투 국내 stop → 구독해제", + ) + _소스필수( + 실패목록, KISWebSocketPriceCache._force_close_socket, + "_unsubscribe_all_before_close", "한투 국내 장양보 → 구독해제", + ) + _소스필수( + 실패목록, KISOverseasWebSocket.stop, + "_unsubscribe_all_before_close", "한투 해외 stop → 구독해제", + ) + _소스필수( + 실패목록, KISOverseasWebSocket._force_close_socket, + "_unsubscribe_all_before_close", "한투 해외 장양보 → 구독해제", + ) + _소스필수( + 실패목록, LSWebSocketPriceCache.stop, + "_graceful_unreg_all", "LS stop → UNREG", + ) + _소스필수( + 실패목록, LSWebSocketPriceCache._force_close_socket, + "_graceful_unreg_all", "LS 장양보 → UNREG", + ) + _소스필수( + 실패목록, LSWebSocketPriceCache._send_raw, + "_force_close_socket", "LS send 실패/락타임아웃 → UNREG 후 close", + ) + _소스필수( + 실패목록, KiwoomWebSocketPriceCache.stop, + "_unsubscribe_all_before_close", "키움 stop → REMOVE", + ) + _소스필수( + 실패목록, WSManager.stop, + "kis_ws_ob.stop", "WSManager.stop → 호가전용 소켓도 stop", + ) + _파일필수( + 실패목록, + "kis_trader/network/kiwoom_condition_manager.py", + "CNSRCLR", + "키움 조건검색 stop → CNSRCLR", + ) + except Exception as exc: + 실패목록.append(f"구독해제 검증 오류: {exc}") + _실패(f"구독해제 검증 오류: {exc}") + import traceback + traceback.print_exc() + + # ── 13) CRITICAL 잠금 (freeze / 호가TTL / OHLC / HTS skip / approval) ─ + print("\n[13단계] CRITICAL 잠금 · 호가 TTL 분리 · Optuna OHLC 폴백 OFF") + try: + from kis_trader.engine.orderbook_filter import _fetch_snapshot + from kis_trader.ws.kis_ws import CandleAggregator + + freeze_on = get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", True) + print( + f" CANDLE_FREEZE={'ON' if freeze_on else 'OFF'} " + f"| DB {_db원문('WS_CANDLE_FREEZE_ON_CONFIRM')} " + f"(코드기본=True, OFF는 사용자 명시 승인 전 금지)" + ) + if not freeze_on: + 실패목록.append("WS_CANDLE_FREEZE_ON_CONFIRM=OFF") + _실패("봉 freeze OFF — 사용자 승인 없이 금지") + else: + _성공("봉 freeze ON (확정봉 OHLCV 유지)") + _파일금지( + 실패목록, + "kis_trader/ws/kis_ws.py", + 'get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", False)', + "kis_ws freeze 코드기본 True", + ) + _소스필수( + 실패목록, CandleAggregator.merge_confirmed_bars, + "WS_CANDLE_FREEZE_ON_CONFIRM", + "갭보정 병합 경로에 freeze 키 존재", + ) + + _소스필수( + 실패목록, _fetch_snapshot, + "WS_ORDERBOOK_FILTER_MAX_AGE_SEC", + "호가필터 판정나이 = FILTER_MAX_AGE", + ) + _소스필수( + 실패목록, _fetch_snapshot, + "WS_ORDERBOOK_TICK_MAX_AGE_SEC", + "호가 저장TTL = TICK_MAX_AGE (판정과 분리)", + ) + print( + f" 호가나이 DB: FILTER_MAX_AGE={_db원문('WS_ORDERBOOK_FILTER_MAX_AGE_SEC')} " + f"| TICK_MAX_AGE={_db원문('WS_ORDERBOOK_TICK_MAX_AGE_SEC')}" + ) + + _파일필수( + 실패목록, + "kis_trader/backtest/param_search_optuna.py", + 'base_params["backtest_tick_fallback_ohlc"] = False', + "Optuna OHLC 폴백 강제 OFF", + ) + + for sid in 전략목록: + 키 = f"{sid}_SKIP_HTS_SCAN_DUPES" + if 키 not in ENV_CONFIG_KEYS: + 실패목록.append(f"ENV 누락 {키}") + _실패(f"ENV_CONFIG_KEYS 에 {키} 없음") + else: + on = get_env_bool(키, False) + print(f" {_전략표시(sid)} {키}={'ON' if on else 'OFF'} | DB {_db원문(키)}") + if on: + _주의(f"{키}=true — 운영 기본은 false. 사용자 지시 없이 바꾸지 말 것") + else: + _성공(f"{키}=false (운영 기본 유지)") + + _파일필수( + 실패목록, + "kis_trader/strategies/base.py", + "TICK_SELL_ASYNC_PLACE", + "틱매도 place 비동기(수신스레드 REST 금지)", + ) + _파일필수( + 실패목록, + "kis_trader/strategies/base.py", + "_tick_sell_worker_loop", + "틱매도 워커", + ) + _파일필수( + 실패목록, + "kis_trader/strategies/base.py", + "_sell_inflight", + "틱·루프 종목당 매도 1장", + ) + if "TICK_SELL_ASYNC_PLACE" not in ENV_CONFIG_KEYS: + 실패목록.append("ENV 누락 TICK_SELL_ASYNC_PLACE") + _실패("ENV_CONFIG_KEYS 에 TICK_SELL_ASYNC_PLACE 없음") + else: + _성공("TICK_SELL_ASYNC_PLACE env 등록") + + _소스필수( + 실패목록, KISWebSocketPriceCache._get_approval_key, + "KISApprovalManager.instance", + "한투 approval_key 공유 캐시", + ) + except Exception as exc: + 실패목록.append(f"CRITICAL 잠금 검증 오류: {exc}") + _실패(f"CRITICAL 잠금 검증 오류: {exc}") + import traceback + traceback.print_exc() + # ── 최종 ─────────────────────────────────────────────────────── print("\n" + "=" * 78) if 실패목록: @@ -702,10 +1107,49 @@ def run_validation() -> bool: print("=" * 78) return False print("최종: 통과 (위 DB ON/OFF 표기가 실매 기준과 일치하는지 함께 확인)") + print("👑 [최종 판정] 완결!") print("=" * 78) return True +class _Tee: + def __init__(self, *files: Any) -> None: + self.files = files + + def write(self, s: str) -> None: + for f in self.files: + f.write(s) + f.flush() + + def flush(self) -> None: + for f in self.files: + f.flush() + + +def _setup_run_log() -> tuple: + log_dir = ROOT / "logs" + log_dir.mkdir(exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = log_dir / f"test_live_execution_validation_{ts}.log" + fp = path.open("w", encoding="utf-8") + sys.stdout = _Tee(sys.__stdout__, fp) # type: ignore[assignment] + sys.stderr = _Tee(sys.__stderr__, fp) # type: ignore[assignment] + print(f"로그 파일: {path}") + return path, fp + + if __name__ == "__main__": - ok = run_validation() - sys.exit(0 if ok else 1) + log_path, log_fp = _setup_run_log() + code = 1 + try: + ok = run_validation() + print(f"로그 파일: {log_path}") + code = 0 if ok else 1 + finally: + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + try: + log_fp.close() + except Exception: + pass + sys.exit(code)