#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 키움 ka10080 (주식분봉차트조회) 를 이용해 과거 분봉 데이터를 일괄 수집하여 ``ws_candles`` 테이블에 UPSERT 한다. 목적 ---- 봇이 실시간으로만 캔들을 쌓으면 백테스트 기간이 짧아 파람서치 신뢰도가 낮다. 키움은 과거 봉을 수백~수천 봉까지 조회할 수 있으므로, 이 스크립트로 미리 채워두면 내일부터 바로 2주치 이상 백테스트를 돌릴 수 있다. 대상 종목 --------- 기본적으로 **최근 N일 ``target_candidates_history``** 에 등장한 종목 (SCALP ∪ SHORT 합집합). ``--codes`` 로 특정 종목만 지정 가능. 대상 타임프레임 ---------------- ``1, 3, 15, 60`` 분 (WS_TIMEFRAMES 와 동일). 스캘핑/꼬리잡기 모두 커버. 키움 API 키 ----------- DB ``env_config`` 테이블에서 로드한다. ``KIS_MOCK`` 값에 따라 실전/모의를 자동 선택하며, 없으면 레거시 ``KIWOOM_APP_KEY`` / ``KIWOOM_APP_SECRET`` 을 사용한다. 사용 예 -------- # 7일치, 전체 유니버스, 1/3/15/60분봉 전부 (기본값) python3 kis_trader/scripts/fill_kiwoom_candles.py # 14일치로 확장 python3 kis_trader/scripts/fill_kiwoom_candles.py --days 14 # 특정 종목만 python3 kis_trader/scripts/fill_kiwoom_candles.py --codes 005930,000660 # 1분봉/3분봉만 python3 kis_trader/scripts/fill_kiwoom_candles.py --timeframes 1,3 # 호출만 하고 DB 쓰지 않기 (검증용) python3 kis_trader/scripts/fill_kiwoom_candles.py --dry-run --codes 005930 """ from __future__ import annotations import argparse import logging import random import sys import time from datetime import datetime, timedelta from pathlib import Path from typing import Iterable, List, Optional, Sequence # ─── sys.path: 구봇 루트 (database.py, kis_ws.py) 접근 ───────────────── HERE = Path(__file__).resolve() ROOT = HERE.parents[2] # /home/hoon/kis_bot if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from database import TradeDB # noqa: E402 from kis_trader.ws.kis_ws import get_kiwoom_candles_df # noqa: E402 logger = logging.getLogger("fill_kiwoom_candles") # ─── 상수 ───────────────────────────────────────────────────────────── DEFAULT_TIMEFRAMES = (1, 3, 15, 60) DEFAULT_DAYS = 7 # 장중 거래시간(분): 09:00 ~ 15:30 = 390분 # 여유분(시간외/초과 수집) 1.3배 곱해서 요청. _MINUTES_PER_DAY = 390 # 타임프레임별 1영업일 당 기대 봉 수 def _bars_per_day(tf_min: int) -> int: return max(1, _MINUTES_PER_DAY // tf_min) # ─── 키움 키 로드 ───────────────────────────────────────────────────── def _load_kiwoom_keys(db: TradeDB) -> tuple[str, str, bool]: """env_config 에서 키움 앱키/시크릿/모의여부를 로드. 우선순위 1. KIS_MOCK=true → KIWOOM_APP_KEY_MOCK/SECRET_MOCK 2. KIS_MOCK=false → KIWOOM_APP_KEY_REAL/SECRET_REAL 3. 위 둘 다 비어있으면 레거시 KIWOOM_APP_KEY/SECRET """ row = db.conn.execute( "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" ).fetchone() if not row: raise RuntimeError("env_config 테이블이 비어 있습니다.") r = dict(row) is_mock = str(r.get("KIS_MOCK", "")).strip().lower() in ("true", "1", "yes", "on") if is_mock: key = str(r.get("KIWOOM_APP_KEY_MOCK", "") or "").strip() sec = str(r.get("KIWOOM_APP_SECRET_MOCK", "") or "").strip() mode = "모의" else: key = str(r.get("KIWOOM_APP_KEY_REAL", "") or "").strip() sec = str(r.get("KIWOOM_APP_SECRET_REAL", "") or "").strip() mode = "실전" if not key or not sec: key = str(r.get("KIWOOM_APP_KEY", "") or "").strip() sec = str(r.get("KIWOOM_APP_SECRET", "") or "").strip() mode += "(레거시)" if not key or not sec: raise RuntimeError( "키움 API 키 미설정. env_config 에 KIWOOM_APP_KEY_REAL/MOCK 또는 " "레거시 KIWOOM_APP_KEY 가 있어야 합니다." ) logger.info("🔑 키움 %s 키 로드 완료 (is_mock=%s)", mode, is_mock) return key, sec, is_mock # ─── 유니버스 코드 추출 ──────────────────────────────────────────────── def _load_target_codes( db: TradeDB, *, days: int, strategies: Sequence[str], explicit_codes: Optional[Sequence[str]] = None, ) -> List[str]: """대상 종목 코드 리스트 반환. ``explicit_codes`` 가 있으면 그것만 사용. 없으면 최근 ``days`` 일 ``target_candidates_history`` 에서 ``strategies`` 에 속한 코드를 합집합으로 추출. """ if explicit_codes: codes = [c.strip() for c in explicit_codes if c.strip()] logger.info("📌 --codes 지정 → %d종목", len(codes)) return codes placeholders = ",".join(["%s"] * len(strategies)) sql = f""" SELECT DISTINCT code FROM target_candidates_history WHERE strategy_id IN ({placeholders}) AND event_time >= DATE_SUB(NOW(), INTERVAL %s DAY) ORDER BY code """ params = tuple(strategies) + (int(days),) rows = db.conn.execute(sql, params).fetchall() codes = [dict(r)["code"] for r in rows] logger.info( "📌 최근 %d일 target_candidates_history ∪%s → %d종목", days, list(strategies), len(codes), ) return codes # ─── DB UPSERT 헬퍼 ──────────────────────────────────────────────────── # WS_CANDLE_FREEZE_ON_CONFIRM(기본 true): 존재 행 OHLCV 유지 — docs/정합성.md _INSERT_SQL_FREEZE = """ INSERT INTO ws_candles (code, timeframe, candle_time, `open`, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE candle_time=candle_time """ _INSERT_SQL_OVERWRITE = """ INSERT INTO ws_candles (code, timeframe, candle_time, `open`, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE `open`=VALUES(`open`), high=VALUES(high), low=VALUES(low), close=VALUES(close), volume=VALUES(volume), is_confirmed=VALUES(is_confirmed), updated_at=VALUES(updated_at) """ def _insert_sql() -> str: from kis_trader.utils.env import get_env_bool if get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", True): return _INSERT_SQL_FREEZE return _INSERT_SQL_OVERWRITE def _upsert_candles( db: TradeDB, code: str, tf_min: int, df, # pd.DataFrame ) -> int: """DataFrame → ws_candles UPSERT. 성공한 행 수 반환.""" if df is None or df.empty: return 0 now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") rows = [] for _, rec in df.iterrows(): try: ct = str(rec["time"])[:12] if len(ct) < 12: continue rows.append(( code, int(tf_min), ct, float(rec["open"]), float(rec["high"]), float(rec["low"]), float(rec["close"]), int(rec["volume"]), None, None, None, # rsi_2, rsi_3, rsi_5 (키움 갭보정에는 없음) 1, # is_confirmed (이미 확정봉) "kw_rest", # source (ws_candles.source 는 varchar(10)) now_str, )) except Exception as e: logger.debug("row skip (%s %dM): %s", code, tf_min, e) if not rows: return 0 # pymysql executemany 직접 사용 with db.conn._lock: db.conn._ensure_connected() cur = db.conn._conn.cursor() cur.executemany(_insert_sql(), rows) db.conn._conn.commit() return len(rows) # ─── 메인 루프 ──────────────────────────────────────────────────────── def run( *, days: int, timeframes: Sequence[int], strategies: Sequence[str], explicit_codes: Optional[Sequence[str]] = None, dry_run: bool = False, per_code_sleep: float = 0.35, per_tf_sleep: float = 0.25, ) -> None: db = TradeDB() kw_key, kw_sec, is_mock = _load_kiwoom_keys(db) codes = _load_target_codes( db, days=days, strategies=strategies, explicit_codes=explicit_codes, ) if not codes: logger.warning("대상 종목 0개. 종료합니다.") return total_calls = len(codes) * len(timeframes) logger.info( "🚀 갭보정 시작: %d종목 × %dTF = %d호출 | 기간 %d일 | dry_run=%s", len(codes), len(timeframes), total_calls, days, dry_run, ) cutoff_yyyymmdd = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d") done_calls = 0 total_rows = 0 err_codes: list[tuple[str, int, str]] = [] t_start = time.time() for ci, code in enumerate(codes, start=1): for tf in timeframes: done_calls += 1 # 봉 수 = days × (1영업일 봉 수) × 1.3 (여유) — 페이지네이션으로 필요한만큼만 n_req = int(_bars_per_day(tf) * days * 1.3) + 10 try: df = get_kiwoom_candles_df( code, tf, kw_key, kw_sec, is_mock=is_mock, n=n_req, ) except Exception as e: err_codes.append((code, tf, str(e))) logger.warning("❌ %s %dM: %s", code, tf, e) time.sleep(per_tf_sleep) continue if df is None or df.empty: logger.debug("… %s %dM: 빈 응답", code, tf) time.sleep(per_tf_sleep) continue # 요청한 기간만 필터 try: df2 = df[df["time"].astype(str).str[:8] >= cutoff_yyyymmdd] except Exception: df2 = df if dry_run: logger.info( "🟡 [dry-run] %s %dM: 키움응답 %d봉, cutoff 이후 %d봉", code, tf, len(df), len(df2), ) else: n_saved = _upsert_candles(db, code, tf, df2) total_rows += n_saved if ci <= 3 or ci % 10 == 0 or tf == timeframes[-1]: elapsed = time.time() - t_start eta_s = (elapsed / done_calls) * (total_calls - done_calls) logger.info( "💾 [%4d/%4d] %s %2dM: +%4d봉 | 누적%6d봉 | ETA %.1f분", done_calls, total_calls, code, tf, n_saved, total_rows, eta_s / 60.0, ) # TF 간 짧은 sleep time.sleep(per_tf_sleep + random.uniform(0.0, 0.1)) # 종목 간 sleep time.sleep(per_code_sleep + random.uniform(0.0, 0.15)) elapsed_total = time.time() - t_start logger.info("=" * 70) logger.info( "✅ 완료: %d종목 × %dTF = %d호출 | 총 UPSERT %d봉 | 소요 %.1f분 | 실패 %d건", len(codes), len(timeframes), total_calls, total_rows, elapsed_total / 60.0, len(err_codes), ) if err_codes: logger.warning("실패 목록 (최대 10건만 표시):") for c, tf, msg in err_codes[:10]: logger.warning(" %s %dM: %s", c, tf, msg[:120]) # ─── CLI ────────────────────────────────────────────────────────────── def _parse_csv_ints(s: str) -> List[int]: return [int(x.strip()) for x in s.split(",") if x.strip()] def _parse_csv_str(s: str) -> List[str]: return [x.strip() for x in s.split(",") if x.strip()] def main(argv: Optional[Iterable[str]] = None) -> int: ap = argparse.ArgumentParser( description="키움 ka10080 으로 ws_candles 과거 봉 일괄 갭보정", formatter_class=argparse.RawDescriptionHelpFormatter, ) ap.add_argument( "--days", type=int, default=DEFAULT_DAYS, help=f"수집 기간(일수, 기본 {DEFAULT_DAYS})", ) ap.add_argument( "--timeframes", type=_parse_csv_ints, default=list(DEFAULT_TIMEFRAMES), help=f"타임프레임 CSV (기본 {','.join(map(str, DEFAULT_TIMEFRAMES))})", ) ap.add_argument( "--strategies", type=_parse_csv_str, default=["SCALP", "SHORT"], help="유니버스 추출 대상 전략 (기본 SCALP,SHORT)", ) ap.add_argument( "--codes", type=_parse_csv_str, default=None, help="특정 종목코드 CSV (지정 시 --strategies 무시)", ) ap.add_argument( "--dry-run", action="store_true", help="키움 호출만 하고 DB 에 쓰지 않음 (검증용)", ) ap.add_argument( "--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"], help="로그 레벨 (기본 INFO)", ) args = ap.parse_args(list(argv) if argv is not None else None) logging.basicConfig( level=getattr(logging, args.log_level), format="%(asctime)s %(levelname)s [%(name)s] %(message)s", datefmt="%H:%M:%S", ) run( days=args.days, timeframes=args.timeframes, strategies=args.strategies, explicit_codes=args.codes, dry_run=args.dry_run, ) return 0 if __name__ == "__main__": sys.exit(main())