"""섹터 매핑 단일 소스 (sector_mapper) 배경 (CLAUDE.md §8.5 미해결 항목 8 — 섹터 매핑 커버리지 44/760): `correlation_filter._STOCK_CODE_SECTOR_MAP` 은 약 44종목만 덮는데 유니버스는 네이버 시가총액 상위 ~760종목이라, §5.1 "섹터당 최대 3종목" 규칙이 후보의 94%에 적용되지 않았다. 이 모듈은 섹터 정보를 **실제 데이터 소스**에서 가져와 파일 캐시에 적재하고, 실시간 진입 판정 경로에는 **캐시 읽기만** 노출한다. 데이터 소스 (2026-09-27 실측): ① 업종 그룹 목록: GET https://m.stock.naver.com/api/stocks/industry?page=1&pageSize=100 → {"groups":[{"no":278,"name":"반도체와반도체장비","totalCount":171}, ...]} (79 groups) ② 업종별 구성종목: GET https://m.stock.naver.com/api/stocks/industry/{no}?page=N&pageSize=100 → {"stocks":[{"itemCode":"005930","stockName":"삼성전자", ...}], "totalCount":...} 실측 결과: 79개 업종 중 no=25 "기타"(1,542종목, ETF/ETN/우선주/스팩 등)를 제외하면 2,870종목이 실업종에 매핑되고, 시총 상위 유니버스(831종목, ETF 키워드 제외 후) 기준 커버리지 810/831 = 97.5% (미매핑 21종목은 전부 ETF/ETN). 아키텍처 제약 (CLAUDE.md §3.1 — 가장 중요): - **실시간 계층(진입 판정, 콜백, 스케줄러 tick)은 네트워크·DB I/O 금지.** → 조회 API `get_sector()` / `get_industry_name()` 는 순수 동기 함수이며 프로세스 메모리 + (스로틀된) 로컬 파일 읽기만 수행한다. 네트워크 호출 없음. - 캐시 채우기(`refresh_sector_map()`)는 배치/명시적 호출 전용 (collect_sector_map.py). 조회 순서 (get_sector): ① 프로세스 메모리 (파일 캐시 로드 결과) ② 큐레이션 정적 표 오버라이드 (`correlation_filter._STOCK_CODE_SECTOR_MAP`) — 44종목은 의도적 그룹핑(예: LG화학/삼성SDI/에코프로 → battery)이라 외부 업종명보다 우선한다. 정적 표는 삭제하지 않고 폴백 겸 오버라이드로 유지. ③ 파일 캐시(외부 소스 결과) ④ None (미매핑) * 외부 소스 호출은 ①~④ 어디에도 들어가지 않는다 (refresh 경로 전용). 캐시 위치·형식: ~/.cache/auto-stock/sector_map.json (korea_investment_api 토큰 캐시와 동일 규약, /tmp 금지) {"version":1,"updated_at":"2026-09-27T12:00:00+09:00","source":"naver_industry", "stats":{"groups":79,"stocks":2870,"skipped_groups":[25]}, "industries":{"005930":"반도체와반도체장비", ...}, "sectors":{"005930":"semiconductor", ...}} """ from __future__ import annotations import asyncio import logging import os import time from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple import httpx from app.core.json_helpers import _dumps logger = logging.getLogger(__name__) # ─ 캐시 파일 위치 ───────────────────────────────────────────────────────────── # korea_investment_api.TOKEN_CACHE_FILE 과 동일한 규약(~/.cache/auto-stock/). /tmp 금지. _DEFAULT_CACHE_PATH = Path.home() / ".cache" / "auto-stock" / "sector_map.json" def get_cache_path() -> Path: """섹터 캐시 파일 경로. `AUTOSTOCK_SECTOR_CACHE_PATH` 로 덮어쓸 수 있다.""" override = os.environ.get("AUTOSTOCK_SECTOR_CACHE_PATH") return Path(override) if override else _DEFAULT_CACHE_PATH # ─ 외부 소스 (네이버 업종) ──────────────────────────────────────────────────── _NAVER_GROUPS_URL = "https://m.stock.naver.com/api/stocks/industry" _NAVER_GROUP_MEMBERS_URL = "https://m.stock.naver.com/api/stocks/industry/{no}" _NAVER_PAGE_SIZE = 100 # 실측 최대값 (200은 HTTP 400) _HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"} # no=25 "기타" 는 ETF/ETN/우선주/스팩/리츠가 뒤섞인 1,500+ 종목 쓰레기통이다. # 이걸 하나의 섹터로 받으면 "기타" 거대 버킷이 생겨 섹터캡이 사실상 총보유 상한으로 # 바뀐다(프로젝트 메모리 "filters-killing-trades" 의 실패 모드). → 수집 단계에서 제외. _SKIP_INDUSTRY_NOS = frozenset({25}) # ─ 섹터 분류 체계 통일 ──────────────────────────────────────────────────────── # 정적 표(_STOCK_CODE_SECTOR_MAP)가 쓰는 라벨을 정본(canonical)으로 삼고, 네이버 업종명 # 79개를 20개 상위 섹터로 묶는다. 묶음 규칙: # - 상관관계가 실제로 높은 밸류체인끼리만 합친다 (반도체+디스플레이장비 등). # - 어떤 버킷도 유니버스의 과반을 먹지 않도록 granularity 를 20개 수준으로 유지. # - 표에 없는 신규 업종은 "기타" 로 뭉치지 않고 `naver_<업종번호>` 개별 버킷으로 둔다. CANONICAL_SECTORS: Tuple[str, ...] = ( "semiconductor", "it_software", "it_hardware", "telecom", "auto", "bio_pharma", "healthcare", "chemicals", "battery", "energy", "materials", "industrial", "construction", "transport", "finance", "consumer_staples", "consumer_discretionary", "retail", "media", "education", ) _NAVER_INDUSTRY_TO_SECTOR: Dict[str, str] = { # 반도체 밸류체인 "반도체와반도체장비": "semiconductor", "디스플레이장비및부품": "semiconductor", "디스플레이패널": "semiconductor", # 소프트웨어·인터넷·게임 "IT서비스": "it_software", "소프트웨어": "it_software", "게임엔터테인먼트": "it_software", "양방향미디어와서비스": "it_software", "인터넷과카탈로그소매": "it_software", # IT 하드웨어 "전자장비와기기": "it_hardware", "컴퓨터와주변기기": "it_hardware", "전자제품": "it_hardware", "사무용전자제품": "it_hardware", "핸드셋": "it_hardware", # 통신 "통신장비": "telecom", "무선통신서비스": "telecom", "다각화된통신서비스": "telecom", # 자동차 "자동차": "auto", "자동차부품": "auto", # 제약·바이오 "제약": "bio_pharma", "생물공학": "bio_pharma", "생명과학도구및서비스": "bio_pharma", # 의료기기·서비스 (제약과 분리 — 주가 동인이 다름) "건강관리장비와용품": "healthcare", "건강관리업체및서비스": "healthcare", "건강관리기술": "healthcare", # 2차전지 — 네이버 "전기제품"(no=283) 구성종목 실측: LG에너지솔루션/에코프로비엠/ # 엘앤에프/엔켐/SK아이이테크놀로지/WCP 등 배터리 밸류체인이 대부분이라 battery 로 묶는다 # (정적 표의 battery 라벨과 동일 버킷 → 라벨 체계 일관). "전기제품": "battery", # 화학·소재 "화학": "chemicals", "포장재": "chemicals", # 에너지·유틸리티 "석유와가스": "energy", "에너지장비및서비스": "energy", "전기유틸리티": "energy", "가스유틸리티": "energy", "복합유틸리티": "energy", # 금속·건자재 "철강": "materials", "비철금속": "materials", "종이와목재": "materials", "건축자재": "materials", "건축제품": "materials", # 산업재·기계 "기계": "industrial", "전기장비": "industrial", "조선": "industrial", "우주항공과국방": "industrial", "상업서비스와공급품": "industrial", "복합기업": "industrial", "무역회사와판매업체": "industrial", # 건설·부동산 "건설": "construction", "부동산": "construction", # 운송 "항공사": "transport", "해운사": "transport", "항공화물운송과물류": "transport", "도로와철도운송": "transport", "운송인프라": "transport", # 금융 "은행": "finance", "증권": "finance", "생명보험": "finance", "손해보험": "finance", "기타금융": "finance", "카드": "finance", "창업투자": "finance", # 필수소비재 "식품": "consumer_staples", "음료": "consumer_staples", "담배": "consumer_staples", "식품과기본식료품소매": "consumer_staples", "가정용품": "consumer_staples", # 경기소비재 "화장품": "consumer_discretionary", "섬유,의류,신발,호화품": "consumer_discretionary", "레저용장비와제품": "consumer_discretionary", "가구": "consumer_discretionary", "가정용기기와용품": "consumer_discretionary", "호텔,레스토랑,레저": "consumer_discretionary", "다각화된소비자서비스": "consumer_discretionary", "문구류": "consumer_discretionary", # 유통 "백화점과일반상점": "retail", "판매업체": "retail", "전문소매": "retail", # 미디어 "방송과엔터테인먼트": "media", "광고": "media", "출판": "media", # 교육 "교육서비스": "education", } def canonical_sector_for(industry_name: Optional[str], industry_no: Optional[int] = None) -> Optional[str]: """네이버 업종명 → 정본 섹터 라벨. - 매핑 표에 있으면 해당 상위 섹터. - 표에 없는(신규 상장 업종 등) 경우 `naver_<번호>` 개별 버킷 → "기타" 거대 버킷 방지. - 업종명·번호 모두 없으면 None. """ if industry_name: mapped = _NAVER_INDUSTRY_TO_SECTOR.get(industry_name.strip()) if mapped: return mapped if industry_no is not None: if int(industry_no) in _SKIP_INDUSTRY_NOS: return None return f"naver_{int(industry_no)}" return None # ─ 프로세스 메모리 캐시 (파일 로드 결과) ────────────────────────────────────── _cache_payload: Dict = {} _cache_sectors: Dict[str, str] = {} _cache_industries: Dict[str, str] = {} _cache_mtime: float = -1.0 _cache_last_stat: float = 0.0 _CACHE_STAT_INTERVAL = 300.0 # 파일 변경 감지 stat 간격(초) — 실시간 경로 디스크 I/O 스로틀 def _curated_overrides() -> Dict[str, str]: """`correlation_filter._STOCK_CODE_SECTOR_MAP` (순환 임포트 회피용 지연 임포트). 큐레이션 44종목은 의도적 그룹핑이므로 외부 업종보다 우선한다. 임포트 실패 시 빈 dict → 파일 캐시만 사용. """ try: from app.services.correlation_filter import _STOCK_CODE_SECTOR_MAP return _STOCK_CODE_SECTOR_MAP except Exception: # pragma: no cover - 순환 임포트/부팅 순서 방어 return {} def load_cache(force: bool = False) -> Dict: """파일 캐시를 프로세스 메모리로 로드 (mtime 변경 시에만 재파싱). 네트워크·DB 접근 없음. `force=False` 이면 `_CACHE_STAT_INTERVAL` 마다 한 번만 stat. """ global _cache_payload, _cache_sectors, _cache_industries, _cache_mtime, _cache_last_stat now = time.monotonic() if not force and _cache_mtime >= 0 and (now - _cache_last_stat) < _CACHE_STAT_INTERVAL: return _cache_payload _cache_last_stat = now path = get_cache_path() try: mtime = path.stat().st_mtime except OSError: if _cache_mtime < 0: _cache_mtime = 0.0 # 파일 없음 — 반복 stat 은 스로틀에 맡긴다 return _cache_payload if not force and mtime == _cache_mtime: return _cache_payload try: import json # 읽기 전용 파싱 (쓰기는 _dumps 사용) with open(path, "r", encoding="utf-8") as fp: payload = json.load(fp) sectors = {str(k): str(v) for k, v in (payload.get("sectors") or {}).items() if v} industries = {str(k): str(v) for k, v in (payload.get("industries") or {}).items() if v} _cache_payload = payload _cache_sectors = sectors _cache_industries = industries _cache_mtime = mtime logger.info( "[SECTOR_MAP] 캐시 로드: %d종목 (updated_at=%s, path=%s)", len(sectors), payload.get("updated_at"), path, ) except Exception as exc: logger.warning("[SECTOR_MAP] 캐시 로드 실패 (%s): %s", path, exc) return _cache_payload def get_sector(stock_code: str) -> Optional[str]: """종목의 정본 섹터 라벨 (동기, 네트워크·DB 호출 없음). 조회 순서: 큐레이션 정적 표 → 파일 캐시(외부 소스) → None. 캐시 미스는 None(미매핑) — 실시간 경로에서 외부 조회로 채우지 않는다. """ if not stock_code: return None code = str(stock_code).strip() curated = _curated_overrides().get(code) if curated: return curated load_cache() return _cache_sectors.get(code) def get_industry_name(stock_code: str) -> Optional[str]: """원본 네이버 업종명 (동기, 네트워크 호출 없음). 진단·리포팅용.""" if not stock_code: return None load_cache() return _cache_industries.get(str(stock_code).strip()) def get_coverage(universe_codes: Optional[List[str]] = None) -> Dict: """섹터 커버리지 스냅샷 (읽기 전용, 네트워크·DB 호출 없음). Args: universe_codes: 비교 대상 유니버스 종목코드. None 이면 캐시 자체 통계만. Returns: {"cache_path","updated_at","source","cached_stocks","curated_stocks","total_mapped", "sector_histogram","universe_size","universe_covered","universe_coverage_pct", "universe_unmapped_sample"} """ payload = load_cache(force=True) curated = _curated_overrides() merged = dict(_cache_sectors) merged.update(curated) # 큐레이션 우선 (get_sector 와 동일 규칙) histogram: Dict[str, int] = {} for sector in merged.values(): histogram[sector] = histogram.get(sector, 0) + 1 result: Dict = { "cache_path": str(get_cache_path()), "updated_at": payload.get("updated_at"), "source": payload.get("source"), "cached_stocks": len(_cache_sectors), "curated_stocks": len(curated), "total_mapped": len(merged), "sector_histogram": dict(sorted(histogram.items(), key=lambda kv: -kv[1])), } if universe_codes: uni = [str(c).strip() for c in universe_codes if c] covered = [c for c in uni if c in merged] unmapped = [c for c in uni if c not in merged] result.update({ "universe_size": len(uni), "universe_covered": len(covered), "universe_coverage_pct": round(100.0 * len(covered) / len(uni), 2) if uni else 0.0, "universe_unmapped_sample": unmapped[:30], }) return result # ─ 캐시 채우기 (배치/명시적 호출 전용 — 실시간 경로에서 호출 금지) ──────────── async def _fetch_json(client: httpx.AsyncClient, url: str, params: Dict, retries: int = 3) -> Optional[Dict]: """GET + JSON 파싱 (지수 백오프 재시도). verify=True 는 클라이언트 생성 시 보장.""" delay = 1.0 for attempt in range(1, retries + 1): try: resp = await client.get(url, params=params, headers=_HEADERS, timeout=10.0) resp.raise_for_status() return resp.json() except Exception as exc: if attempt >= retries: logger.warning("[SECTOR_MAP] 조회 실패 %s %s: %s", url, params, exc) return None await asyncio.sleep(delay) delay *= 2 return None async def fetch_industry_groups(client: httpx.AsyncClient) -> List[Dict]: """네이버 업종 그룹 목록 [{"no":278,"name":"반도체와반도체장비","totalCount":171}, ...].""" groups: List[Dict] = [] page = 1 while page <= 20: # 안전 상한 (실측 79그룹 = 1페이지) data = await _fetch_json(client, _NAVER_GROUPS_URL, {"page": page, "pageSize": _NAVER_PAGE_SIZE}) if not data: break rows = data.get("groups") or [] if not rows: break groups.extend(rows) if len(rows) < _NAVER_PAGE_SIZE: break page += 1 await asyncio.sleep(0.15) return groups async def fetch_industry_members(client: httpx.AsyncClient, industry_no: int) -> List[str]: """특정 업종의 구성 종목코드 목록.""" codes: List[str] = [] page = 1 while page <= 50: # 안전 상한 (최대 업종 175종목 = 2페이지) data = await _fetch_json( client, _NAVER_GROUP_MEMBERS_URL.format(no=int(industry_no)), {"page": page, "pageSize": _NAVER_PAGE_SIZE}, ) if not data: break rows = data.get("stocks") or [] for item in rows: code = (item.get("itemCode") or "").strip() if code: codes.append(code) if len(rows) < _NAVER_PAGE_SIZE: break page += 1 await asyncio.sleep(0.15) return codes async def refresh_sector_map( write_cache: bool = True, request_interval: float = 0.15, ) -> Dict: """외부 소스(네이버 업종)에서 전체 섹터 맵을 수집해 파일 캐시에 기록. ⚠ 네트워크 호출 — **배치/스케줄러/명시적 호출에서만** 사용한다. 진입 판정 등 실시간 경로에서 호출 금지 (§3.1). 실패 시 기존 캐시 파일을 덮어쓰지 않는다(정적 표 + 기존 캐시로 폴백 유지). Returns: {"ok":bool,"groups":int,"stocks":int,"skipped_groups":[...], "unmapped_industries":[...],"cache_path":str,"updated_at":str} """ sectors: Dict[str, str] = {} industries: Dict[str, str] = {} unmapped_industries: List[str] = [] group_count = 0 # verify=True 유지 (CLAUDE.md §4) async with httpx.AsyncClient(verify=True, timeout=10.0) as client: groups = await fetch_industry_groups(client) if not groups: logger.error("[SECTOR_MAP] 업종 그룹 조회 실패 — 캐시 갱신 취소 (기존 캐시/정적 표 유지)") return {"ok": False, "groups": 0, "stocks": 0, "reason": "groups_fetch_failed"} for group in groups: try: no = int(group.get("no")) except (TypeError, ValueError): continue name = (group.get("name") or "").strip() if no in _SKIP_INDUSTRY_NOS: continue sector = canonical_sector_for(name, no) if not sector: continue if name and name not in _NAVER_INDUSTRY_TO_SECTOR: unmapped_industries.append(f"{no}:{name}->{sector}") codes = await fetch_industry_members(client, no) for code in codes: sectors[code] = sector if name: industries[code] = name group_count += 1 await asyncio.sleep(request_interval) if not sectors: logger.error("[SECTOR_MAP] 수집 종목 0건 — 캐시 갱신 취소") return {"ok": False, "groups": group_count, "stocks": 0, "reason": "empty_result"} updated_at = datetime.now().astimezone().isoformat() payload = { "version": 1, "updated_at": updated_at, "source": "naver_industry", "stats": { "groups": group_count, "stocks": len(sectors), "skipped_groups": sorted(_SKIP_INDUSTRY_NOS), "unmapped_industries": unmapped_industries, }, "industries": industries, "sectors": sectors, } result = { "ok": True, "groups": group_count, "stocks": len(sectors), "skipped_groups": sorted(_SKIP_INDUSTRY_NOS), "unmapped_industries": unmapped_industries, "cache_path": str(get_cache_path()), "updated_at": updated_at, } if write_cache: try: await asyncio.to_thread(_write_cache_file, payload) load_cache(force=True) except Exception as exc: logger.error("[SECTOR_MAP] 캐시 파일 기록 실패: %s", exc) result["ok"] = False result["reason"] = f"write_failed: {exc}" if unmapped_industries: logger.warning( "[SECTOR_MAP] 매핑 표에 없는 업종 %d건 → naver_ 개별 버킷 처리: %s", len(unmapped_industries), ", ".join(unmapped_industries[:10]), ) logger.info( "[SECTOR_MAP] 갱신 완료: %d업종 / %d종목 → %s", group_count, len(sectors), get_cache_path() ) return result def _write_cache_file(payload: Dict) -> None: """캐시 파일 원자적 기록 (동기 — asyncio.to_thread 내부에서만 호출).""" path = get_cache_path() path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") with open(tmp, "w", encoding="utf-8") as fp: fp.write(_dumps(payload)) os.replace(tmp, path)