- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
620 lines
25 KiB
Python
620 lines
25 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)
|
|
|
|
|
|
# ─ 캐시 건강도 · 자동 갱신 (R3: 운영에 sector_map.json 이 없어 섹터캡이 15.7% 에만 적용) ──
|
|
# 설계: 조회 경로(get_sector)는 그대로 네트워크 금지. 아래 스케줄러만 네트워크를 쓴다.
|
|
# main.py/schedulers.py 배선은 호출부 소유 그룹이 `sector_map_refresh_scheduler()` 를
|
|
# asyncio.create_task 로 띄우고 /health 에 `get_cache_health_v2()` 를 노출한다.
|
|
SECTOR_CACHE_MAX_AGE_DAYS = 7
|
|
SECTOR_REFRESH_HOUR_KST = 8
|
|
SECTOR_REFRESH_MINUTE_KST = 30
|
|
_SECTOR_REFRESH_RETRY_SEC = 1800.0 # 실패 시 재시도 간격(30분)
|
|
|
|
|
|
def get_cache_health_v2(max_age_days: int = SECTOR_CACHE_MAX_AGE_DAYS) -> Dict:
|
|
"""캐시 파일 존재·나이 점검 (동기, 파일 stat/파싱만 — 네트워크·DB 없음).
|
|
|
|
Returns:
|
|
{"ok":bool,"exists":bool,"stale":bool,"age_days":float|None,"updated_at":str|None,
|
|
"stocks":int,"cache_path":str,"reason":str|None}
|
|
"""
|
|
path = get_cache_path()
|
|
payload = load_cache(force=True)
|
|
result: Dict = {
|
|
"ok": False, "exists": False, "stale": True, "age_days": None,
|
|
"updated_at": None, "stocks": len(_cache_sectors), "cache_path": str(path),
|
|
"reason": None,
|
|
}
|
|
try:
|
|
mtime = path.stat().st_mtime
|
|
except OSError:
|
|
result["reason"] = "missing"
|
|
return result
|
|
result["exists"] = True
|
|
result["updated_at"] = payload.get("updated_at")
|
|
age_ref = mtime
|
|
updated_at = payload.get("updated_at")
|
|
if updated_at:
|
|
try:
|
|
age_ref = datetime.fromisoformat(str(updated_at)).timestamp()
|
|
except ValueError:
|
|
pass # 파싱 불가 시 파일 mtime 기준
|
|
age_days = max(0.0, (time.time() - age_ref) / 86400.0)
|
|
result["age_days"] = round(age_days, 2)
|
|
if not _cache_sectors:
|
|
result["reason"] = "empty_or_unreadable"
|
|
return result
|
|
if age_days > max_age_days:
|
|
result["reason"] = "stale"
|
|
return result
|
|
result.update({"ok": True, "stale": False})
|
|
return result
|
|
|
|
|
|
def log_cache_health_v2() -> Dict:
|
|
"""기동 시 호출: 캐시가 없거나 낡았으면 error 로그 (섹터캡 무력화 식별용)."""
|
|
health = get_cache_health_v2()
|
|
if not health["ok"]:
|
|
logger.error(
|
|
"[SECTOR_MAP] 섹터 캐시 %s (path=%s, age_days=%s) — 큐레이션 44종목 외에는 §5.1 섹터캡 미적용",
|
|
health["reason"], health["cache_path"], health["age_days"],
|
|
)
|
|
return health
|
|
|
|
|
|
def _next_refresh_delay_v2(now: Optional[datetime] = None) -> float:
|
|
"""다음 평일 08:30 KST 까지 남은 초."""
|
|
from datetime import timedelta
|
|
from zoneinfo import ZoneInfo
|
|
kst = ZoneInfo("Asia/Seoul")
|
|
cur = (now or datetime.now(kst)).astimezone(kst)
|
|
target = cur.replace(hour=SECTOR_REFRESH_HOUR_KST, minute=SECTOR_REFRESH_MINUTE_KST,
|
|
second=0, microsecond=0)
|
|
if target <= cur:
|
|
target += timedelta(days=1)
|
|
while target.weekday() >= 5:
|
|
target += timedelta(days=1)
|
|
return max(1.0, (target - cur).total_seconds())
|
|
|
|
|
|
async def sector_map_refresh_scheduler() -> None:
|
|
"""기동 시 캐시 없음/낡음이면 즉시 갱신, 이후 평일 08:30 KST 마다 갱신.
|
|
|
|
실패해도 기존 캐시를 보존(refresh_sector_map 계약)하며 30분 후 재시도한다.
|
|
네트워크는 이 태스크에서만 사용한다(실시간 경로 아님, DB 접근 없음).
|
|
"""
|
|
health = log_cache_health_v2()
|
|
due_now = not health["ok"]
|
|
while True:
|
|
try:
|
|
if not due_now:
|
|
await asyncio.sleep(_next_refresh_delay_v2())
|
|
result = await refresh_sector_map()
|
|
if result.get("ok"):
|
|
due_now = False
|
|
else:
|
|
logger.error("[SECTOR_MAP] 자동 갱신 실패: %s — %d초 후 재시도",
|
|
result.get("reason"), int(_SECTOR_REFRESH_RETRY_SEC))
|
|
await asyncio.sleep(_SECTOR_REFRESH_RETRY_SEC)
|
|
due_now = True
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error("[SECTOR_MAP] 갱신 스케줄러 예외: %s", exc)
|
|
await asyncio.sleep(_SECTOR_REFRESH_RETRY_SEC)
|
|
due_now = True
|