브랜치 분리 방식: A / B / C

A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
2026-05-05 21:04:17 +09:00
parent c2b2b711e0
commit f61c471aac
58 changed files with 803502 additions and 1430 deletions

View File

@@ -0,0 +1,5 @@
"""kis_trader/scripts — 일회성 운영/유지보수 스크립트 모음.
이 디렉토리는 봇 본체(`kis_trader/strategies`, `kis_trader/network` 등) 가
아니라, 수동 실행용 스크립트만 모은다.
"""

View File

@@ -0,0 +1,386 @@
#!/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_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 헬퍼 ────────────────────────────────────────────────────
_INSERT_SQL = """
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 _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())