3 Commits

Author SHA1 Message Date
978aa14f8b feat(stock): apply webai_cache to portfolio/news/screener-preview (SP-A2)
3 endpoint cache 적용 — /api/webai/portfolio, /api/webai/news-sentiment,
/api/stock/screener/run (preview 모드만, auto는 캐시 미적용).
V1+V2 동시 호출도 NAS에서 1회 계산. web-ai 측 SP-A1 캐시와 2-layer로
작동하여 NAS 인바운드 부담 70% 감소 예상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:47:23 +09:00
030365bed0 feat(stock): webai_cache module (TTLCache for SP-A2)
3개의 TTLCache (portfolio 120s · news 600s · screener 180s) +
헬퍼 함수. screener key는 mode + top_n + weights canonical hash로
분기. 다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run
3 endpoint에 적용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:43:24 +09:00
8c5bfa453f chore(stock): add cachetools for server-side TTLCache (SP-A2 prep)
다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run에
in-memory TTLCache 적용 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:41:25 +09:00
6 changed files with 184 additions and 5 deletions

15
stock/app/conftest.py Normal file
View File

@@ -0,0 +1,15 @@
"""Project-level pytest conftest.
SP-A2: autouse fixture that resets all webai_cache TTLCaches between tests
so screener/portfolio/news cache state does not leak across test cases.
"""
import pytest
from app import webai_cache
@pytest.fixture(autouse=True)
def _reset_webai_cache():
webai_cache.PORTFOLIO_CACHE.clear()
webai_cache.NEWS_CACHE.clear()
webai_cache.SCREENER_CACHE.clear()
yield

View File

@@ -25,6 +25,7 @@ from .scraper import fetch_market_news, fetch_major_indices
from .price_fetcher import get_current_prices, get_current_prices_detail from .price_fetcher import get_current_prices, get_current_prices_detail
from .ai_summarizer import summarize_news, OllamaError from .ai_summarizer import summarize_news, OllamaError
from .auth import verify_webai_key from .auth import verify_webai_key
from . import webai_cache
app = FastAPI() app = FastAPI()
@@ -418,8 +419,16 @@ def _augment_portfolio_with_pnl_pct(raw: dict) -> dict:
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)]) @app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
def get_webai_portfolio(): def get_webai_portfolio():
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).""" """web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
return _augment_portfolio_with_pnl_pct(get_portfolio())
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
"""
cached = webai_cache.cache_get_portfolio()
if cached is not None:
return cached
result = _augment_portfolio_with_pnl_pct(get_portfolio())
webai_cache.cache_set_portfolio(result)
return result
def _fetch_news_sentiment_dump(date: str | None) -> dict: def _fetch_news_sentiment_dump(date: str | None) -> dict:
@@ -466,8 +475,16 @@ def _fetch_news_sentiment_dump(date: str | None) -> dict:
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)]) @app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
def get_webai_news_sentiment(date: str | None = None): def get_webai_news_sentiment(date: str | None = None):
"""web-ai 전용 news sentiment 일별 dump.""" """web-ai 전용 news sentiment 일별 dump.
return _fetch_news_sentiment_dump(date)
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
"""
cached = webai_cache.cache_get_news(date)
if cached is not None:
return cached
result = _fetch_news_sentiment_dump(date)
webai_cache.cache_set_news(date, result)
return result
@app.post("/api/portfolio", status_code=201) @app.post("/api/portfolio", status_code=201)

View File

@@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException
from . import schemas from . import schemas
from .registry import NODE_REGISTRY, GATE_REGISTRY from .registry import NODE_REGISTRY, GATE_REGISTRY
from .. import webai_cache
router = APIRouter(prefix="/api/stock/screener") router = APIRouter(prefix="/api/stock/screener")
@@ -173,6 +174,12 @@ def _persist_run(conn, asof, mode, weights, node_params, gate_params, top_n,
@router.post("/run", response_model=schemas.RunResponse) @router.post("/run", response_model=schemas.RunResponse)
def post_run(body: schemas.RunRequest): def post_run(body: schemas.RunRequest):
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
if body.mode == "preview":
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
if cached is not None:
return cached
started_at = dt.datetime.utcnow().isoformat() started_at = dt.datetime.utcnow().isoformat()
with _conn() as c: with _conn() as c:
asof = _resolve_asof(body.asof, c) asof = _resolve_asof(body.asof, c)
@@ -231,7 +238,7 @@ def post_run(body: schemas.RunRequest):
top_n=top_n, rows=result.rows, run_id=run_id, top_n=top_n, rows=result.rows, run_id=run_id,
) )
return schemas.RunResponse( response = schemas.RunResponse(
asof=asof.isoformat(), mode=body.mode, status="success", asof=asof.isoformat(), mode=body.mode, status="success",
run_id=run_id, survivors_count=result.survivors_count, run_id=run_id, survivors_count=result.survivors_count,
weights=weights, top_n=top_n, weights=weights, top_n=top_n,
@@ -239,6 +246,10 @@ def post_run(body: schemas.RunRequest):
telegram_payload=schemas.TelegramPayload(**payload), telegram_payload=schemas.TelegramPayload(**payload),
warnings=result.warnings, warnings=result.warnings,
) )
# SP-A2 — preview 모드 결과 캐시 저장.
if body.mode == "preview":
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
return response
# ---------- /snapshot/refresh ---------- # ---------- /snapshot/refresh ----------

View File

@@ -0,0 +1,67 @@
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
import time
import pytest
from app.webai_cache import (
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
cache_get_portfolio, cache_set_portfolio,
cache_get_news, cache_set_news,
cache_get_screener, cache_set_screener,
_screener_key,
)
def _clear_all():
PORTFOLIO_CACHE.clear()
NEWS_CACHE.clear()
SCREENER_CACHE.clear()
def test_portfolio_cache_miss_then_hit():
_clear_all()
assert cache_get_portfolio() is None
cache_set_portfolio({"holdings": [], "cash": 0})
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
def test_news_cache_key_by_date():
"""date가 다르면 별도 캐시 슬롯."""
_clear_all()
cache_set_news("2026-05-18", {"count": 5})
cache_set_news("2026-05-17", {"count": 3})
assert cache_get_news("2026-05-18") == {"count": 5}
assert cache_get_news("2026-05-17") == {"count": 3}
assert cache_get_news("2026-05-16") is None # not cached
def test_news_cache_latest_key_normalized():
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
_clear_all()
cache_set_news(None, {"count": 9})
assert cache_get_news(None) == {"count": 9}
def test_screener_key_includes_mode_and_top_n():
"""screener key는 mode + top_n + weights hash로 분기."""
k_preview = _screener_key("preview", 20, None)
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
k_auto = _screener_key("auto", 20, None)
assert k_preview != k_preview_w
assert k_preview != k_auto
def test_screener_cache_roundtrip():
_clear_all()
payload = {"asof": "2026-05-18", "survivors_count": 17}
cache_set_screener("preview", 20, None, payload)
assert cache_get_screener("preview", 20, None) == payload
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
def test_ttl_expiry_portfolio():
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
from cachetools import TTLCache
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
short["result"] = "x"
assert short.get("result") == "x"
time.sleep(0.2)
assert short.get("result") is None

68
stock/app/webai_cache.py Normal file
View File

@@ -0,0 +1,68 @@
"""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

View File

@@ -11,4 +11,5 @@ finance-datareader==0.9.110
lxml==6.1.0 lxml==6.1.0
pytest==8.3.2 pytest==8.3.2
pytest-asyncio==0.24.0 pytest-asyncio==0.24.0
cachetools>=5.3