Files
kis_bot/kis_trader/scripts/fill_kiwoom_candles.py
Your Name 61bec4bd1d feat: Add DART strategy and related configurations
ㅇ
Changes:
- Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework.
- Updated the database schema to include DART-specific tables for disclosures and watchlists.
- Enhanced the backtesting and parameter search functionalities to support the DART strategy.
- Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy.

Impact:
- These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
2026-07-21 07:50:24 +09:00

406 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())