""" KRX 전체 종목 동적 로딩 - 서버 시작 시 1회 로딩 - 매일 자정 자동 갱신 - 실패 시 하드코딩 폴백 """ import asyncio import json import io import time from datetime import datetime import httpx import structlog logger = structlog.get_logger() # 폴백 종목 (네트워크 실패 시 사용) FALLBACK_STOCKS = { "코스피": "KOSPI", "코스닥": "KOSDAQ", "코스피200": "KOSPI200", "삼성전자": "005930", "SK하이닉스": "000660", "LG에너지솔루션": "373220", "현대차": "005380", "기아": "000270", "셀트리온": "068270", "카카오": "035720", "네이버": "035420", "NAVER": "035420", "삼성바이오로직스": "207940", "KB금융": "105560", "POSCO홀딩스": "005490", "신한지주": "055550", "LG화학": "051910", "삼성SDI": "006400", "현대모비스": "012330", "하나금융지주": "086790", "SK텔레콤": "017670", "KT": "030200", "LG전자": "066570", "한화에어로스페이스": "012450", "삼성물산": "028260", "HD현대중공업": "329180", "한국전력": "015760", "HMM": "011200", "대한항공": "003490", "카카오뱅크": "323410", "크래프톤": "259960", "하이브": "352820", "에코프로": "086520", "알테오젠": "196170", "한미반도체": "042700", } # 약칭/별칭 — 시장에서 통용되는 줄임말·구명·영문표기 # 주의: 2글자 이하 약칭은 일반어 충돌 위험 (예: "삼전" → "삼전자" 같은 일반어 거의 없어 OK, # 하지만 "엔솔" 같은 건 위험 → "LG엔솔"처럼 회사명 포함 별칭만 유지) ALIASES = { # ─── 시총 상위 한국 종목 ─── # 반도체 "삼전": "005930", "삼성전자": "005930", "하닉": "000660", "SK하닉": "000660", "SK하이닉스": "000660", "에스케이하이닉스": "000660", "한미반도": "042700", "한미반": "042700", "디비하이텍": "000990", "DB하이텍": "000990", "리노공업": "058470", "이수페타시스": "007660", "주성엔지니어링": "036930", "주성": "036930", # 2차전지/배터리 "LG엔솔": "373220", "엘지엔솔": "373220", "LG에너지": "373220", "삼성SDI": "006400", "삼성에스디아이": "006400", "SK이노": "096770", "에스케이이노베이션": "096770", "에코프로비엠": "247540", "에코비엠": "247540", "에코프로": "086520", "포스코퓨처엠": "003670", "퓨처엠": "003670", "포퓨엠": "003670", "엘앤에프": "066970", "L&F": "066970", "엔켐": "348370", # 자동차/모빌리티 "현차": "005380", "현대차": "005380", "현대자동차": "005380", "기아차": "000270", "기아자동차": "000270", "기아": "000270", "현모비스": "012330", "모비스": "012330", "현대모비스": "012330", "현대글로비스": "086280", "글로비스": "086280", "현대위아": "011210", # 바이오/제약 "삼바": "207940", "삼성바이오": "207940", "삼성바이오로직스": "207940", "셀트리": "068270", "셀트리온": "068270", "셀트리온헬스케어": "091990", "한미약": "128940", "한미약품": "128940", "유한양": "000100", "유한양행": "000100", "녹십자": "006280", "알테오젠": "196170", "에이비엘": "298380", "에이비엘바이오": "298380", "리가켐": "141080", "리가켐바이오": "141080", # 인터넷/IT "네이버": "035420", "NAVER": "035420", "카카오": "035720", "카뱅": "323410", "카카오뱅크": "323410", "카페이": "377300", "카카오페이": "377300", "카게임": "293490", "카카오게임즈": "293490", # 게임/엔터 "엔씨": "036570", "엔씨소프트": "036570", "넷마블": "251270", "크래프톤": "259960", "크라프톤": "259960", "위메이드": "112040", "펄어비스": "263750", "하이브": "352820", "BTS하이브": "352820", "SM": "041510", "에스엠": "041510", "JYP": "035900", "JYPEnt": "035900", "와이지": "122870", "YG": "122870", # 방산/조선 "한에솔": "012450", "한화에어": "012450", "한화에어로": "012450", "한화에어로스페이스": "012450", "LIG넥": "079550", "LIG넥스원": "079550", "현대로템": "064350", "로템": "064350", "HD현중": "329180", "HD현대중공업": "329180", "현중": "329180", "한국조선해양": "009540", "HD한국조선": "009540", "HD현대": "267250", "삼성중공업": "010140", "삼중": "010140", "한화오션": "042660", "대우조선": "042660", "한화시스템": "272210", # 화학/소재 "LG화학": "051910", "롯데케미칼": "011170", "롯데케": "011170", "한화솔루션": "009830", "한솔": "009830", "효성첨단": "298050", "효성첨단소재": "298050", "코오롱인": "120110", "코오롱인더": "120110", "금호석화": "011780", "금호석유": "011780", "OCI": "010060", # 철강/소재 "포홀": "005490", "POSCO": "005490", "포스코홀딩스": "005490", "POSCO홀딩스": "005490", "포스코": "005490", "현대제철": "004020", "현제철": "004020", "고려아연": "010130", # 금융/지주 "KB금융": "105560", "케이비금융": "105560", "신한지주": "055550", "신한금융": "055550", "하나금융": "086790", "하나금융지주": "086790", "우리금융": "316140", "우리금융지주": "316140", "기업은행": "024110", "IBK기업은행": "024110", "메리츠금융": "138040", "메리츠금융지주": "138040", "삼성생명": "032830", "삼생": "032830", "삼성화재": "000810", "삼화": "000810", "DB손보": "005830", "DB손해보험": "005830", "현대해상": "001450", "한투지": "071050", "한국금융지주": "071050", "키움증권": "039490", "키움": "039490", "미래에셋": "006800", "미래에셋증권": "006800", # 통신 "SK텔": "017670", "SKT": "017670", "SK텔레콤": "017670", "KT": "030200", "LGU+": "032640", "LG유플러스": "032640", "엘지유플": "032640", # 유통/소비재 "이마트": "139480", "롯데쇼핑": "023530", "신세계": "004170", "현대백화점": "069960", "GS리테일": "007070", "BGF리테일": "282330", "씨유": "282330", "CU": "282330", "오리온": "271560", "농심": "004370", "삼양식품": "003230", "삼양": "003230", "CJ제일제당": "097950", "CJ제일": "097950", "오뚜기": "007310", "하이트진로": "000080", "롯데칠성": "005300", "코카콜라음료": "005440", "아모레퍼시픽": "090430", "아모레": "090430", "LG생건": "051900", "LG생활건강": "051900", "코스맥스": "192820", "한국콜마": "161890", # 건설 "삼성물산": "028260", "현대건설": "000720", "현건": "000720", "GS건설": "006360", "대우건설": "047040", "DL이앤씨": "375500", "디엘이앤씨": "375500", "HDC현대산업개발": "294870", "HDC현산": "294870", # 에너지/유틸리티 "한전": "015760", "한국전력": "015760", "한국가스공사": "036460", "한가공": "036460", "두산에너빌리티": "034020", "두산에너": "034020", "두에빌": "034020", "두산퓨얼셀": "336260", "두산퓨얼": "336260", "두산밥캣": "241560", "두산": "000150", "S-Oil": "010950", "S오일": "010950", "에쓰오일": "010950", "SK이노베이션": "096770", "GS": "078930", "한화": "000880", # 항공/운송 "대한항공": "003490", "대항": "003490", "아시아나": "020560", "아시아나항공": "020560", "제주항공": "089590", "진에어": "272450", "티웨이항공": "091810", "HMM": "011200", "팬오션": "028670", "현대글로비스": "086280", # 우주/위성 "한화에어로스페이스": "012450", "쎄트렉아이": "099320", "쎄트렉": "099320", "인텔리안테크": "189300", "인텔리안": "189300", "AP위성": "211270", # AI/디스플레이 "LG디스플": "034220", "LG디스플레이": "034220", "엘지디스플레이": "034220", "더존비즈온": "012510", "더존": "012510", "삼성전기": "009150", "삼전기": "009150", "LG이노텍": "011070", # 보안/SI "안랩": "053800", "케이아이엔엑스": "093320", "KINX": "093320", } async def fetch_krx_stocks() -> dict[str, str]: """KRX에서 전체 상장 종목 가져오기""" stock_map = { "코스피": "KOSPI", "코스닥": "KOSDAQ", "코스피200": "KOSPI200", "KRX": "KRX", } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "http://data.krx.co.kr/contents/MDC/MDI/mdiLoader/index.cmd", } async with httpx.AsyncClient(timeout=30, headers=headers) as client: for mkt_id, mkt_name in [("STK", "KOSPI"), ("KSQ", "KOSDAQ")]: try: payload = { "bld": "dbms/MDC/STAT/standard/MDCSTAT01901", "locale": "ko_KR", "mktId": mkt_id, "share": "1", "csvxls_is498": "false", } resp = await client.post( "http://data.krx.co.kr/comm/bldAttend/getJsonData.cmd", data=payload, ) if resp.status_code == 200: data = resp.json() items = data.get("OutBlock_1", []) for item in items: name = item.get("ISU_ABBRV", "").strip() code = item.get("ISU_SRT_CD", "").strip() if name and code and len(code) == 6: stock_map[name] = code logger.info("krx.loaded", market=mkt_name, count=len(items)) else: logger.warning("krx.http_error", market=mkt_name, status=resp.status_code) except Exception as e: logger.warning("krx.fetch_failed", market=mkt_name, error=str(e)) # KRX 실패 시 네이버 금융 폴백 if len(stock_map) < 100: logger.info("krx.fallback_naver", reason="KRX returned too few stocks") stock_map.update(await fetch_naver_stocks()) return stock_map async def fetch_naver_stocks() -> dict[str, str]: """네이버 금융에서 종목 가져오기 (KRX 폴백)""" stock_map = {} headers = {"User-Agent": "Mozilla/5.0"} import re async with httpx.AsyncClient(timeout=15, headers=headers) as client: for sosok in [0, 1]: # 0=KOSPI, 1=KOSDAQ for page in range(1, 45): try: url = f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}" resp = await client.get(url) text = resp.content.decode("euc-kr", errors="ignore") rows = re.findall( r"main\.naver\?code=(\d{6})[^>]*>([^<]+)", text ) if not rows: break for code, name in rows: name = name.strip() if name and code: stock_map[name] = code except Exception: break logger.info("naver.loaded", count=len(stock_map)) return stock_map async def load_all_stocks() -> dict[str, str]: """전체 종목 로딩 (KRX → 네이버 → 폴백)""" try: stock_map = await fetch_krx_stocks() if len(stock_map) > 100: stock_map.update(ALIASES) logger.info("stocks.loaded", total=len(stock_map), source="KRX") return stock_map except Exception as e: logger.warning("stocks.krx_failed", error=str(e)) try: stock_map = await fetch_naver_stocks() if len(stock_map) > 100: stock_map.update({ "코스피": "KOSPI", "코스닥": "KOSDAQ", "코스피200": "KOSPI200", "KRX": "KRX", }) stock_map.update(ALIASES) logger.info("stocks.loaded", total=len(stock_map), source="Naver") return stock_map except Exception as e: logger.warning("stocks.naver_failed", error=str(e)) # 최종 폴백 fallback = {**FALLBACK_STOCKS, **ALIASES} logger.warning("stocks.using_fallback", total=len(fallback)) return fallback async def auto_refresh_stocks(state_ref, interval_hours: int = 24): """백그라운드에서 종목 목록 자동 갱신""" while True: await asyncio.sleep(interval_hours * 3600) try: new_map = await load_all_stocks() if len(new_map) > 50: state_ref.stock_map = new_map logger.info("stocks.refreshed", total=len(new_map)) except Exception as e: logger.warning("stocks.refresh_failed", error=str(e))