#!/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, )