Files
kis_bot/fetch_stock_meta.py
2026-03-17 12:33:30 +09:00

328 lines
13 KiB
Python

#!/usr/bin/env python3
"""
fetch_stock_meta.py — 키움 테마 API로 stock_meta 자동 채우기
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[역할]
- 키움 REST API (ka90001 / ka90002) 로 전체 테마 목록 + 구성 종목을 조회
- stock_meta 테이블에 종목별 테마명·테마순위 저장
- 이후 스캘핑/꼬리잡기 매수 시 "이 종목의 테마 60분봉 RSI" 확인 가능
[사용방법]
python3 fetch_stock_meta.py # 전체 테마 스캔 (기본)
python3 fetch_stock_meta.py --top 30 # 상위 30개 테마만 (빠름)
python3 fetch_stock_meta.py --theme AI # 특정 테마명 검색
[systemd timer 등록 예시 — 장 마감 후 1일 1회]
systemctl --user enable kis_fetch_meta.timer
[키움 API 엔드포인트]
ka90001: 테마그룹별 조회 (테마 목록)
ka90002: 테마구성종목 조회 (각 테마 내 종목 리스트)
"""
import argparse
import logging
import sys
import time
import random
from pathlib import Path
from typing import Optional
import requests
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from database import TradeDB
# _get_kiwoom_creds: DB에서 키움 키 꺼내기
# _get_kiwoom_token_cached: 23시간 캐시 토큰 (au10001 rate limit 방지)
from kis_ws import _get_kiwoom_creds, _get_kiwoom_token_cached
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("FetchStockMeta")
# ──────────────────────────────────────────────────────────────
# 키움 토큰: kis_ws._get_kiwoom_token_cached() 재사용
# (23시간 모듈 레벨 캐시 → au10001 rate limit 방지)
# ──────────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────────
# ka90001: 테마 목록 조회
# ──────────────────────────────────────────────────────────────
def fetch_theme_list(
token: str,
app_key: str,
app_secret: str,
is_mock: bool,
theme_name_filter: str = "",
) -> list:
"""
키움 ka90001 API 로 전체 테마 그룹 목록 조회.
Returns:
[{'thema_grp_cd': '100', 'thema_nm': 'AI반도체', 'stk_num': '15', ...}, ...]
"""
domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
url = f"https://{domain}/api/dostk/thme"
headers = {
"content-type": "application/json;charset=UTF-8",
"appkey": app_key,
"appsecret": app_secret,
"authorization": f"Bearer {token}",
"api-id": "ka90001",
"cont-yn": "N",
"next-key": "",
}
# qry_tp=0: 전체검색 / date_tp=1: 1일 / flu_pl_amt_tp=1: 상위기간수익률 / stex_tp=1: KRX
body = {
"qry_tp": "1" if theme_name_filter else "0",
"thema_nm": theme_name_filter,
"stk_cd": "",
"date_tp": "1",
"flu_pl_amt_tp":"1",
"stex_tp": "1",
}
themes = []
while True:
try:
resp = requests.post(url, json=body, headers=headers, timeout=15)
data = resp.json()
except Exception as e:
logger.warning("ka90001 조회 실패: %s", e)
break
groups = data.get("thema_grp") or []
themes.extend(groups)
cont_yn = str(resp.headers.get("cont-yn", "N")).upper()
next_key = str(resp.headers.get("next-key", "")).strip()
if cont_yn != "Y" or not next_key:
break
headers["cont-yn"] = cont_yn
headers["next-key"] = next_key
time.sleep(0.4)
logger.info("✅ 테마 목록 조회 완료: %d", len(themes))
return themes
# ──────────────────────────────────────────────────────────────
# ka90002: 테마 구성 종목 조회
# ──────────────────────────────────────────────────────────────
def fetch_theme_stocks(
token: str,
app_key: str,
app_secret: str,
is_mock: bool,
thema_grp_cd: str,
) -> list:
"""
키움 ka90002 API 로 특정 테마의 구성 종목 조회.
Returns:
[{'stk_cd': '005930', 'stk_nm': '삼성전자', ...}, ...]
"""
domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com"
url = f"https://{domain}/api/dostk/thme"
headers = {
"content-type": "application/json;charset=UTF-8",
"appkey": app_key,
"appsecret": app_secret,
"authorization": f"Bearer {token}",
"api-id": "ka90002",
"cont-yn": "N",
"next-key": "",
}
body = {
"thema_grp_cd": thema_grp_cd,
"stex_tp": "1",
"date_tp": "1",
}
stocks = []
while True:
try:
resp = requests.post(url, json=body, headers=headers, timeout=15)
data = resp.json()
except Exception as e:
logger.warning("ka90002 조회 실패 (테마%s): %s", thema_grp_cd, e)
break
items = data.get("thema_comp_stk") or []
stocks.extend(items)
cont_yn = str(resp.headers.get("cont-yn", "N")).upper()
next_key = str(resp.headers.get("next-key", "")).strip()
if cont_yn != "Y" or not next_key:
break
headers["cont-yn"] = cont_yn
headers["next-key"] = next_key
time.sleep(0.35)
return stocks
# ──────────────────────────────────────────────────────────────
# 종목코드로 시장구분 추정 (KOSPI / KOSDAQ / ETF)
# 정확한 구분은 별도 API 필요, 여기선 휴리스틱
# ──────────────────────────────────────────────────────────────
def _guess_market(code: str) -> str:
"""
KOSPI ETF 코드 범위 (069XXX, 102XXX 등) / 기타 판별은 어려워
가장 보수적으로: 6자리 숫자 기준 추정.
- 0XXXXX, 1XXXXX: 주로 KOSPI (삼성 005930, 현대 005380)
- 2XXXXX, 3XXXXX: 주로 KOSDAQ (카카오 035720)
- ETF는 069XXX, 102XXX, 114XXX, 122XXX, 133XXX, 229XXX 등
완벽하지 않음 — 향후 KIS FHKST01010100 API로 정확히 수정 가능
"""
if not code or len(code) != 6 or not code.isdigit():
return "Q"
n = int(code)
# 대표적 ETF 코드 대역
etf_ranges = [
(69000, 70000), (102000, 103000), (114000, 115000),
(122000, 123000), (133000, 134000), (229000, 230000),
(252000, 253000), (261000, 262000), (278000, 279000),
(364000, 365000),
]
for lo, hi in etf_ranges:
if lo <= n < hi:
return "E"
# KOSPI 범위 (0~200000 사이, 대략)
if n < 200000:
return "K"
return "Q"
# ──────────────────────────────────────────────────────────────
# 메인: 테마 → 종목 → stock_meta 저장
# ──────────────────────────────────────────────────────────────
def run(top_n: int = 0, theme_filter: str = "", dry_run: bool = False):
"""
Args:
top_n : 상위 N개 테마만 처리 (0=전체)
theme_filter : 특정 테마명 검색 (예: "AI", "반도체")
dry_run : DB 저장 없이 출력만
"""
db = TradeDB()
# 1. DB에서 키움 키 꺼내기
kw_key, kw_secret, kw_mock = _get_kiwoom_creds(db)
if not kw_key or not kw_secret:
logger.error("❌ 키움 앱키/시크릿 없음 → update_env_simple.py 로 KIWOOM_APP_KEY_REAL 설정 필요")
sys.exit(1)
mode_str = "모의" if kw_mock else "실전"
logger.info("🔑 키움 키 확인 완료 [%s] (앞8자: %s…)", mode_str, kw_key[:8])
# 2. 키움 OAuth 토큰 (캐시 사용)
token = _get_kiwoom_token_cached(kw_key, kw_secret, kw_mock)
if not token:
sys.exit(1)
logger.info("✅ 키움 토큰 확인 완료")
# 3. 테마 목록 조회
themes = fetch_theme_list(token, kw_key, kw_secret, kw_mock, theme_filter)
if not themes:
logger.error("❌ 테마 목록 없음 (API 응답 확인 필요)")
sys.exit(1)
if top_n > 0:
themes = themes[:top_n]
logger.info("📋 상위 %d개 테마만 처리", top_n)
# 4. 테마별 구성 종목 조회 + stock_meta 저장
total_saved = 0
total_skipped = 0
for idx, tg in enumerate(themes, 1):
grp_cd = str(tg.get("thema_grp_cd") or "").strip()
theme_nm = str(tg.get("thema_nm") or "").strip()
stk_num = str(tg.get("stk_num") or "0").strip()
if not grp_cd or not theme_nm:
continue
logger.info("[%d/%d] 테마: %s (%s종목)", idx, len(themes), theme_nm, stk_num)
stocks = fetch_theme_stocks(token, kw_key, kw_secret, kw_mock, grp_cd)
if not stocks:
logger.debug(" → 구성 종목 없음 (스킵)")
total_skipped += 1
continue
for rank, stk in enumerate(stocks, 1):
code = str(stk.get("stk_cd") or "").strip()
name = str(stk.get("stk_nm") or "").strip()
if not code or len(code) != 6:
continue
# 테마 내 순위: 상위 3위까지 핵심주(1), 4~10위 연관주(2), 나머지 주변주(3)
theme_rank = 1 if rank <= 3 else (2 if rank <= 10 else 3)
market = _guess_market(code)
if dry_run:
print(f" {code} {name:12s} market={market} theme={theme_nm} rank={theme_rank}")
else:
ok = db.upsert_stock_meta(
code = code,
name = name,
market = market,
sector = "", # 섹터는 별도 API 필요 (KIS FHKST01010100)
theme = theme_nm,
theme_rank = theme_rank,
)
if ok:
total_saved += 1
if dry_run:
total_saved += 1
time.sleep(random.uniform(0.3, 0.6)) # API 레이트리밋
# 5. 결과 요약
if not dry_run:
total_stocks = db.conn.execute("SELECT COUNT(*) AS n FROM stock_meta").fetchone()['n']
logger.info("=" * 50)
logger.info("✅ 저장 완료: %d건 | stock_meta 전체: %d종목", total_saved, total_stocks)
# 테마별 종목 수 TOP 10
rows = db.conn.execute("""
SELECT theme, COUNT(*) AS cnt
FROM stock_meta
WHERE theme IS NOT NULL AND theme != ''
GROUP BY theme
ORDER BY cnt DESC
LIMIT 10
""").fetchall()
logger.info("📊 테마별 종목 수 TOP 10:")
for r in rows:
logger.info(" %-25s: %d종목", r['theme'], r['cnt'])
else:
logger.info("(dry-run 모드: DB 저장 안 함, 조회만 %d건)", total_saved)
# ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="키움 테마 API → stock_meta DB 자동 채우기")
ap.add_argument("--top", type=int, default=0, help="상위 N개 테마만 처리 (0=전체)")
ap.add_argument("--theme", type=str, default="", help="특정 테마명 검색 (예: AI, 반도체)")
ap.add_argument("--dry-run",action="store_true", help="DB 저장 없이 출력만")
args = ap.parse_args()
run(
top_n = args.top,
theme_filter= args.theme,
dry_run = args.dry_run,
)