""" kis_trader/backtest/breakout_tick_loader.py — ws_ticks 로드·분봉 인덱싱 (B안 백테) """ from __future__ import annotations from collections import defaultdict from datetime import datetime, timedelta from typing import Any, Dict, Iterator, List, Optional, Set, Tuple from ..utils.env import get_env_bool, get_env_from_db, get_env_int from ..utils.logger import get_logger logger = get_logger("kis_trader.breakout_tick_loader") def _candle_keys_to_tick_range(start_key: str, end_key: str) -> Tuple[str, str]: """ws_candles 키(12자리) → ws_ticks tick_time(14자리) 범위.""" s = (start_key or "")[:12] e = (end_key or "")[:12] return s + "00", e + "59" def _prefer_kiwoom_minute_ticks( ticks: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """ 실매 후보 경로는 키움 체결량(FID15) 기준. 같은 분에 kis(과거 BIDP1 오염 volume)가 섞이면 거래량 필터가 가짜 통과하므로 키움 틱이 1건이라도 있으면 키움만 사용. 키움 없으면 kis/기타 유지. """ if not ticks: return ticks kw = [ t for t in ticks if str(t.get("source") or "").strip().lower() == "kiwoom" ] return kw if kw else ticks def _ws_ticks_table(market: str) -> str: """국내 ws_ticks / 해외 ws_ticks_us.""" m = str(market or "KR").strip().upper() try: from database import TradeDB return TradeDB.ws_ticks_table_for_market(m) except Exception: return "ws_ticks_us" if m == "US" else "ws_ticks" def _iter_tick_day_chunks(tt_start: str, tt_end: str) -> Iterator[Tuple[str, str]]: """ 틱 구간을 달력일 단위로 자른다. 4일치·수백만 행을 한 방 SELECT 하면 TradeDB 기본 read_timeout(30s)에 걸린다. """ s = str(tt_start or "").strip().ljust(14, "0")[:14] e = str(tt_end or "").strip().ljust(14, "0")[:14] if len(s) < 8 or len(e) < 8 or s > e: return d0 = datetime.strptime(s[:8], "%Y%m%d") d1 = datetime.strptime(e[:8], "%Y%m%d") cur = d0 while cur <= d1: day = cur.strftime("%Y%m%d") chunk_s = max(s, day + "000000") chunk_e = min(e, day + "235959") if chunk_s <= chunk_e: yield chunk_s, chunk_e cur += timedelta(days=1) def _open_tick_load_conn(read_timeout: int): """벌크 틱 조회 전용 연결 (기본 TradeDB 30s read_timeout 우회).""" import pymysql import pymysql.cursors from database import _DB_HOST, _DB_NAME, _DB_PASS, _DB_PORT, _DB_USER # 읽기 타임아웃(초) — 하루치 ~100만행 SELECT 대비. DB/env: WS_TICK_LOAD_READ_TIMEOUT to = max(30, int(read_timeout)) return pymysql.connect( host=_DB_HOST, port=int(_DB_PORT), user=_DB_USER, password=_DB_PASS, database=_DB_NAME, charset="utf8mb4", autocommit=True, cursorclass=pymysql.cursors.DictCursor, connect_timeout=10, read_timeout=to, write_timeout=30, ) def _fetch_ws_ticks_day_rows( table: str, mkt: str, chunk_s: str, chunk_e: str, codes: Optional[Set[str]], ) -> List[Dict[str, Any]]: """ 하루(또는 부분일) 틱 SELECT. ORDER BY 는 MySQL 정렬 비용이 커서 빼고, 호출 측에서 분봉 버킷 정렬. """ # 읽기 타임아웃(초) / 재시도 횟수 — 하드코딩 금지, DB·env read_timeout = get_env_int("WS_TICK_LOAD_READ_TIMEOUT", 180) max_retries = max(1, get_env_int("WS_TICK_LOAD_MAX_RETRIES", 2)) code_filter = "" params: List[Any] = [mkt, chunk_s, chunk_e] if codes: placeholders = ",".join(["%s"] * len(codes)) code_filter = f" AND code IN ({placeholders})" params.extend(sorted(codes)) sql = f""" SELECT code, tick_time, price, volume, source FROM {table} WHERE market = %s AND tick_time >= %s AND tick_time <= %s {code_filter} """ last_err: Optional[BaseException] = None for attempt in range(1, max_retries + 1): conn = None try: conn = _open_tick_load_conn(read_timeout) with conn.cursor() as cur: cur.execute(sql, tuple(params)) rows = cur.fetchall() or [] return list(rows) except Exception as e: last_err = e logger.warning( "%s 일별 조회 실패 (day=%s~%s attempt=%s/%s): %s", table, chunk_s[:8], chunk_e[:8], attempt, max_retries, e, ) finally: if conn is not None: try: conn.close() except Exception: pass if last_err is not None: raise last_err return [] def _ingest_tick_rows( rows: List[Dict[str, Any]], out: Dict[str, Dict[str, List[Dict[str, Any]]]], ) -> int: """SELECT 행 → 종목·분봉 버킷. 반환=적재 건수.""" n = 0 for r in rows: code = str(r["code"]).strip() tt = str(r["tick_time"])[:14] if len(tt) < 12: continue minute_key = tt[:12] tick = { "code": code, "tick_time": tt, "price": float(r["price"] or 0), "volume": int(r.get("volume") or 0), "source": r.get("source") or "", } bucket = out[code].setdefault(minute_key, []) bucket.append(tick) n += 1 return n def load_breakout_ticks_by_code( db, start_key: str, end_key: str, codes: Optional[Set[str]] = None, *, market: Optional[str] = None, ) -> Tuple[Dict[str, Dict[str, List[Dict[str, Any]]]], int]: """ 기간 내 체결 틱을 종목·분봉(YYYYMMDDHHMM) 단위로 로드. - KR → ``ws_ticks`` - US → ``ws_ticks_us`` (해외 전용 InnoDB) - 다일은 **일별 청크** + 긴 read_timeout (4일 한 방 조회 타임아웃 방지) Returns: (``{code: {minute_key: [tick, ...]}}``, total_tick_rows) """ mkt = (market or get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper() tt_start, tt_end = _candle_keys_to_tick_range(start_key, end_key) # 해외는 키움 체결 없음(kis_us) — 키움 우선 필터 끄기 prefer_kw = bool(get_env_bool("WS_TICK_PREFER_KIWOOM", True)) and mkt != "US" table = _ws_ticks_table(mkt) try: if mkt == "US" and hasattr(db, "ensure_ws_ticks_us_table"): db.ensure_ws_ticks_us_table() elif hasattr(db, "ensure_ws_ticks_table"): db.ensure_ws_ticks_table() except Exception: pass chunks = list(_iter_tick_day_chunks(tt_start, tt_end)) if not chunks: logger.warning("%s 조회 범위 없음: %s ~ %s", table, tt_start, tt_end) return {}, 0 out: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(dict) total = 0 failed_days = 0 read_timeout = get_env_int("WS_TICK_LOAD_READ_TIMEOUT", 180) logger.info( "📥 %s 일별 로드 시작 | days=%s | read_timeout=%ss | codes=%s", table, len(chunks), read_timeout, len(codes) if codes else "ALL", ) for chunk_s, chunk_e in chunks: try: rows = _fetch_ws_ticks_day_rows(table, mkt, chunk_s, chunk_e, codes) n = _ingest_tick_rows(rows, out) total += n logger.info( "✅ %s day=%s rows=%s (누적=%s)", table, chunk_s[:8], n, total, ) except Exception as e: failed_days += 1 logger.warning( "%s day=%s 조회 실패 — 해당일 스킵: %s", table, chunk_s[:8], e, ) if total <= 0: logger.warning( "%s 조회 실패 — OHLC 폴백만 사용: 0건 (failed_days=%s/%s)", table, failed_days, len(chunks), ) return {}, 0 if failed_days > 0: logger.warning( "⚠️ %s 부분 로드: failed_days=%s/%s · loaded=%s", table, failed_days, len(chunks), total, ) # 일별 SELECT 에 ORDER BY 없음 → 분봉 버킷 시간순 정렬 (틱 재생 정합) for _code, minutes in out.items(): for _mk, ticks in minutes.items(): ticks.sort(key=lambda t: str(t.get("tick_time") or "")) # 실매(키움 구독) 정합: 분봉 단위 키움 우선 (모멘텀/꼬리 로더가 이 함수 재사용) if prefer_kw and out: kept = 0 for code, minutes in out.items(): for mk, ticks in list(minutes.items()): filtered = _prefer_kiwoom_minute_ticks(ticks) minutes[mk] = filtered kept += len(filtered) if kept != total: logger.info( "📌 WS_TICK_PREFER_KIWOOM: raw=%d → kept=%d (분봉내 키움 우선)", total, kept, ) total = kept return dict(out), total def _infer_bar_tf_min(candles: List[Dict], default: int = 1) -> int: """연속 candle_time 간격의 중앙값으로 봉주기(분) 추정. 실패 시 default.""" from kis_trader.engine.candle_rollup import minute_diff times = sorted({ str(c.get("candle_time") or "")[:12] for c in (candles or []) if len(str(c.get("candle_time") or "")[:12]) >= 12 }) if len(times) < 2: return max(1, int(default)) diffs = [] for i in range(1, min(len(times), 80)): d = minute_diff(times[i - 1], times[i]) if d is not None and d > 0: diffs.append(int(d)) if not diffs: return max(1, int(default)) diffs.sort() return max(1, int(diffs[len(diffs) // 2])) def _bar_has_ticks( candle_time: str, minute_map: Dict[str, List], tf_min: int, ) -> bool: """ N분봉이면 시작~시작+tf 구간의 **어느 1분이라도** 틱이 있으면 커버. (옛 로직은 시작분만 봐 → 꼬리 3M에서 커버가 과소 집계됨) """ from kis_trader.engine.candle_rollup import add_candle_minutes ct = str(candle_time or "")[:12] if len(ct) < 12 or not minute_map: return False tf = max(1, int(tf_min)) for i in range(tf): mk = ct if i == 0 else add_candle_minutes(ct, i) if mk and minute_map.get(mk): return True return False def tick_coverage_stats( codes_candles: Dict[str, List[Dict]], ticks_by_code: Dict[str, Dict[str, List[Dict]]], *, bar_tf_min: Optional[int] = None, ) -> Dict[str, Any]: """ 분봉 대비 틱 보유 비율 (백테 메타용). 왜 '전체 %'가 낮아 보이는가 ───────────────────────── 분봉(ws_candles)은 REST 갭보정으로 **구독 전·틱 없는 구간**까지 채워지고, 틱(ws_ticks)은 **구독 중·체결 있는 분**만 쌓인다. 분모에 REST 봉을 넣으면 커버가 낮게 나온다 (기록 누락이 아님). 반환 키 ─────── - tick_bar_coverage_pct: **구독구간**(종목별 첫틱~끝틱) 커버 — UI 주표시 - tick_bar_coverage_pct_all: 전체 봉(REST 웜업 포함) - tick_bar_coverage_pct_friend: 틱이 1건이라도 있는 종목만 """ total_bars = 0 covered_all = 0 covered_exact_legacy = 0 friend_bars = 0 friend_covered = 0 sub_bars = 0 sub_covered = 0 codes_with_any = 0 for code, candles in (codes_candles or {}).items(): minute_map = ticks_by_code.get(code) or {} has_ticks = bool(minute_map) if has_ticks: codes_with_any += 1 tf = int(bar_tf_min) if bar_tf_min and int(bar_tf_min) > 0 else _infer_bar_tf_min( candles, default=1, ) tick_keys = [str(k)[:12] for k in minute_map.keys() if k] first_m = min(tick_keys) if tick_keys else "" last_m = max(tick_keys) if tick_keys else "" for c in candles or []: ct = str(c.get("candle_time") or "")[:12] if len(ct) < 12: continue total_bars += 1 hit = _bar_has_ticks(ct, minute_map, tf) if minute_map.get(ct): covered_exact_legacy += 1 if hit: covered_all += 1 if has_ticks: friend_bars += 1 if hit: friend_covered += 1 # 구독구간: 첫 틱 분 ~ 마지막 틱 분 (봉 시작 시각 기준) if first_m and last_m and first_m <= ct <= last_m: sub_bars += 1 if hit: sub_covered += 1 pct_all = (covered_all / total_bars * 100.0) if total_bars else 0.0 pct_friend = (friend_covered / friend_bars * 100.0) if friend_bars else 0.0 pct_sub = (sub_covered / sub_bars * 100.0) if sub_bars else 0.0 pct_legacy = (covered_exact_legacy / total_bars * 100.0) if total_bars else 0.0 return { # UI·로그 주표시 = 구독구간 (실매 '보면서 기록'에 가장 가깝다) "tick_bar_coverage_pct": round(pct_sub, 2), "tick_bar_coverage_pct_subscribed": round(pct_sub, 2), "tick_bars_subscribed_total": sub_bars, "tick_bars_subscribed_covered": sub_covered, "tick_bar_coverage_pct_all": round(pct_all, 2), "tick_bar_coverage_pct_friend": round(pct_friend, 2), "tick_bar_coverage_pct_legacy_exact": round(pct_legacy, 2), "tick_bars_total": total_bars, "tick_bars_covered": covered_all, "tick_bars_friend_total": friend_bars, "tick_bars_friend_covered": friend_covered, "tick_codes_with_data": codes_with_any, "tick_codes_total": len(codes_candles or {}), } def enrich_tick_meta_with_traded_codes( tick_meta: Optional[Dict[str, Any]], candles_by_code: Dict[str, List[Dict]], ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], trades: Optional[List[Dict]], *, bar_tf_min: Optional[int] = None, ) -> Dict[str, Any]: """ 백테 **체결(거래) 종목만** 구독구간 커버를 tick_meta 에 보강. UI: ``거래종목 NN%`` — 산 친구 칸만 센 값. """ out: Dict[str, Any] = dict(tick_meta or {}) codes = sorted({ str(t.get("code") or "").strip() for t in (trades or []) if str(t.get("code") or "").strip() }) out["tick_codes_traded"] = len(codes) if not codes: return out sub_candles = { c: candles_by_code[c] for c in codes if c in (candles_by_code or {}) and candles_by_code.get(c) } if not sub_candles: return out ticks = ticks_by_code or {} sub_ticks = {c: ticks.get(c) or {} for c in sub_candles} sub = tick_coverage_stats(sub_candles, sub_ticks, bar_tf_min=bar_tf_min) out["tick_bar_coverage_pct_traded"] = sub.get("tick_bar_coverage_pct") out["tick_bars_traded_subscribed_total"] = sub.get("tick_bars_subscribed_total") out["tick_bars_traded_subscribed_covered"] = sub.get("tick_bars_subscribed_covered") return out def build_tick_coverage_meta_for_day( db, codes: Set[str], start_ymd: str, end_ymd: str, *, timeframe: int = 1, trades: Optional[List[Dict]] = None, ) -> Dict[str, Any]: """ 실매·운영 탭용: 당일(기간) ws_candles + ws_ticks 로 분봉커버 메타 생성. UI 통일 — 백테 ``tick_backtest`` 와 동일 키. """ codes_clean = sorted({str(c).strip() for c in (codes or set()) if str(c).strip()}) if not codes_clean or not db: return {} start_key = str(start_ymd or "").replace("-", "")[:8] + "0900" end_key = str(end_ymd or start_ymd or "").replace("-", "")[:8] + "1530" if len(start_key) < 12 or len(end_key) < 12: return {} day_like = start_key[:8] + "%" tf = max(1, int(timeframe or 1)) placeholders = ",".join(["%s"] * len(codes_clean)) try: rows = db.conn.execute( f"SELECT code, candle_time, open, high, low, close, volume " f"FROM ws_candles WHERE timeframe=%s AND candle_time LIKE %s " f"AND code IN ({placeholders})", tuple([tf, day_like] + codes_clean), ).fetchall() except Exception as e: logger.warning("build_tick_coverage_meta_for_day candles: %s", e) return {} candles_by_code: Dict[str, List[Dict]] = defaultdict(list) for r in rows or []: code = str(r.get("code") or "").strip() ct = str(r.get("candle_time") or "")[:12] if not code or len(ct) < 12: continue candles_by_code[code].append({ "candle_time": ct, "open": r.get("open"), "high": r.get("high"), "low": r.get("low"), "close": r.get("close"), "volume": r.get("volume"), }) if not candles_by_code: return {"tick_codes_traded": len(codes_clean), "tick_bars_total": 0} ticks_by_code, tick_rows = load_breakout_ticks_by_code( db, start_key, end_key, set(candles_by_code.keys()), ) meta = tick_coverage_stats(dict(candles_by_code), ticks_by_code, bar_tf_min=tf) meta["ws_tick_rows_loaded"] = tick_rows trade_list = trades if trades is not None else [{"code": c} for c in codes_clean] meta = enrich_tick_meta_with_traded_codes( meta, dict(candles_by_code), ticks_by_code, trade_list, bar_tf_min=tf, ) return meta def first_tick_minute_key( ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], code: str, ) -> Optional[str]: """종목 ws_ticks 중 가장 이른 분봉 키(YYYYMMDDHHMM). 없으면 None.""" if not ticks_by_code or not code: return None minute_map = ticks_by_code.get(code) or {} if not minute_map: return None keys = [str(k)[:12] for k in minute_map.keys() if k] return min(keys) if keys else None def entry_before_first_tick( ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]], code: str, entry_bar_time: str, ) -> bool: """정합용: 진입봉이 첫 틱 분 이전이면 True (매수 스킵 대상).""" first = first_tick_minute_key(ticks_by_code, code) if not first: return False et = str(entry_bar_time or "")[:12] if not et: return False return et < first