fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 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>
This commit is contained in:
515
backend/app/services/sector_mapper.py
Normal file
515
backend/app/services/sector_mapper.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""섹터 매핑 단일 소스 (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)
|
||||
Reference in New Issue
Block a user