브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
5
kis_trader/scripts/__init__.py
Normal file
5
kis_trader/scripts/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""kis_trader/scripts — 일회성 운영/유지보수 스크립트 모음.
|
||||
|
||||
이 디렉토리는 봇 본체(`kis_trader/strategies`, `kis_trader/network` 등) 가
|
||||
아니라, 수동 실행용 스크립트만 모은다.
|
||||
"""
|
||||
386
kis_trader/scripts/fill_kiwoom_candles.py
Normal file
386
kis_trader/scripts/fill_kiwoom_candles.py
Normal 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())
|
||||
Reference in New Issue
Block a user