"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache. web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를 KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측. V1+V2가 동시 호출해도 NAS는 1회만 계산. TTL 정책 (spec §10 SP-A2): - portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능) - news: 600s (sentiment는 일 단위) - screener: 180s """ from __future__ import annotations import hashlib import json from typing import Any, Optional from cachetools import TTLCache PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0) NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0) SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0) # ----- portfolio ----- def cache_get_portfolio() -> Optional[Any]: return PORTFOLIO_CACHE.get("result") def cache_set_portfolio(value: Any) -> None: PORTFOLIO_CACHE["result"] = value # ----- news-sentiment ----- def _news_key(date: Optional[str]) -> str: return date if date else "latest" def cache_get_news(date: Optional[str]) -> Optional[Any]: return NEWS_CACHE.get(_news_key(date)) def cache_set_news(date: Optional[str], value: Any) -> None: NEWS_CACHE[_news_key(date)] = value # ----- screener ----- def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str: """mode + top_n + weights canonical hash. weights 객체 동등성을 키로.""" if weights is None: w_repr = "none" else: # canonical: sorted keys → md5 hex (긴 weights도 짧은 키로) canon = json.dumps(weights, sort_keys=True, ensure_ascii=False) w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12] return f"{mode}:{top_n}:{w_repr}" def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]: return SCREENER_CACHE.get(_screener_key(mode, top_n, weights)) def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None: SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value