""" kis_trader/backtest/breakout_tick_loader.py — ws_ticks 로드·분봉 인덱싱 (B안 백테) """ from __future__ import annotations from collections import defaultdict from typing import Any, Dict, List, Optional, Set, Tuple from ..utils.env import get_env_from_db 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 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]: """ 기간 내 ``ws_ticks`` 를 종목·분봉(YYYYMMDDHHMM) 단위로 로드. 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) try: if hasattr(db, "ensure_ws_ticks_table"): db.ensure_ws_ticks_table() except Exception: pass code_filter = "" params: List[Any] = [mkt, tt_start, tt_end] 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 ws_ticks WHERE market = %s AND tick_time >= %s AND tick_time <= %s {code_filter} ORDER BY code, tick_time """ try: rows = db.conn.execute(sql, tuple(params)).fetchall() except Exception as e: logger.warning("ws_ticks 조회 실패 — OHLC 폴백만 사용: %s", e) return {}, 0 out: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(dict) total = 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) total += 1 return dict(out), total def tick_coverage_stats( codes_candles: Dict[str, List[Dict]], ticks_by_code: Dict[str, Dict[str, List[Dict]]], ) -> Dict[str, Any]: """분봉 대비 틱 보유 비율 (백테 메타용).""" total_bars = 0 bars_with_ticks = 0 codes_with_any = 0 for code, candles in codes_candles.items(): minute_map = ticks_by_code.get(code) or {} if minute_map: codes_with_any += 1 for c in candles: ct = str(c.get("candle_time") or "")[:12] if not ct: continue total_bars += 1 if minute_map.get(ct): bars_with_ticks += 1 ratio = (bars_with_ticks / total_bars * 100.0) if total_bars else 0.0 return { "tick_bars_total": total_bars, "tick_bars_covered": bars_with_ticks, "tick_bar_coverage_pct": round(ratio, 2), "tick_codes_with_data": codes_with_any, "tick_codes_total": len(codes_candles), }