커서가 망쳐놓은 듯
This commit is contained in:
327
fetch_stock_meta.py
Normal file
327
fetch_stock_meta.py
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/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,
|
||||
)
|
||||
Reference in New Issue
Block a user