## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
516 lines
21 KiB
Python
516 lines
21 KiB
Python
"""섹터 매핑 단일 소스 (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_<no> 개별 버킷 처리: %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)
|