Changes: - Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically. - Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database. - Introduced a mechanism to handle master subscription states, improving the management of subscription statuses. - Updated the database schema to include new fields for managing subscription states and order book filtering. Impact: - These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies. 히스토리 align 제거 븅신같은 초기설계 아예 제거 진입모드에 구멍메움 호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
407 lines
14 KiB
Python
407 lines
14 KiB
Python
#!/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, channel, updated_at)
|
||
VALUES
|
||
(%s, %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, channel, updated_at)
|
||
VALUES
|
||
(%s, %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 (이미 확정봉)
|
||
"kiwoom", # source = 증권사
|
||
"rest", # channel = 키움 ka10080 갭보정
|
||
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())
|