Files
auto-stock/backend/app/services/sector_mapper.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

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