"""테마/카테고리 API. 토스 '주식 골라보기' 의 테마 그리드와 비슷한 결. 정적 매핑(seed/themes.py)을 베이스로, DB 에서 최신 종가/등락만 채워 응답. GET /api/themes - 모든 테마 인덱스 (slug/name/description/code_count) GET /api/themes/{slug} ?limit=20 - 해당 테마의 종목 카드 (종목명/시장/종가/등락률). 거래량 기준 정렬. - ohlcv_daily 가 아직 안 채워져 있으면 close/pct_change 가 NULL. """ from __future__ import annotations from fastapi import APIRouter, HTTPException, Query from sqlalchemy import text from app.db.connection import get_engine from app.seed.themes import THEMES, theme_by_slug, themes_index router = APIRouter(prefix="/api/themes", tags=["themes"]) @router.get("") def list_themes() -> dict: return {"items": themes_index(), "total": len(THEMES)} @router.get("/{slug}") def get_theme(slug: str, limit: int = Query(default=30, ge=1, le=100)) -> dict: th = theme_by_slug(slug) if th is None: raise HTTPException(status_code=404, detail=f"unknown theme: {slug}") codes = list(th.codes)[:limit] if not codes: return { "slug": th.slug, "name": th.name, "description": th.description, "items": [], } sql = text( """ WITH last2 AS ( SELECT DISTINCT date FROM ohlcv_daily ORDER BY date DESC LIMIT 2 ), bounds AS (SELECT MAX(date) AS d0, MIN(date) AS d1 FROM last2), latest AS ( SELECT o.code, o.close AS close0, o.volume AS vol0 FROM ohlcv_daily o, bounds WHERE o.date = bounds.d0 ), prev AS ( SELECT o.code, o.close AS close1 FROM ohlcv_daily o, bounds WHERE o.date = bounds.d1 ) SELECT s.code, s.name, s.market, l.close0 AS close, p.close1 AS prev_close, l.vol0 AS volume, CASE WHEN p.close1 > 0 THEN (l.close0 - p.close1) / p.close1 * 100 ELSE NULL END AS pct_change FROM symbols s LEFT JOIN latest l ON l.code = s.code LEFT JOIN prev p ON p.code = s.code WHERE s.code = ANY(:codes) ORDER BY vol0 DESC NULLS LAST, s.name ASC """ ) eng = get_engine() with eng.connect() as conn: rows = conn.execute(sql, {"codes": codes}).all() # symbols 테이블에 시드만 있고 codes 일부가 누락된 경우, 그래도 코드/이름 자리는 비워서 카드 표시. by_code = {r[0]: r for r in rows} items: list[dict] = [] for c in codes: r = by_code.get(c) if r is None: items.append( { "code": c, "name": c, # symbols 미등재 — 코드로 노출 "market": None, "close": None, "prev_close": None, "volume": None, "pct_change": None, } ) else: items.append( { "code": r[0], "name": r[1], "market": r[2], "close": float(r[3]) if r[3] is not None else None, "prev_close": float(r[4]) if r[4] is not None else None, "volume": int(r[5]) if r[5] is not None else None, "pct_change": float(r[6]) if r[6] is not None else None, } ) return { "slug": th.slug, "name": th.name, "description": th.description, "items": items, }