{data.name}
+{data.description}
+diff --git a/backend/app/api/themes.py b/backend/app/api/themes.py
new file mode 100644
index 0000000..3c84f2f
--- /dev/null
+++ b/backend/app/api/themes.py
@@ -0,0 +1,111 @@
+"""테마/카테고리 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,
+ }
diff --git a/backend/app/main.py b/backend/app/main.py
index a012651..6d0b5a3 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -15,6 +15,7 @@ from app.api.news import router as news_router
from app.api.predict import router as predict_router
from app.api.refresh import router as refresh_router
from app.api.symbols import router as symbols_router
+from app.api.themes import router as themes_router
from app.config import settings
from app.db.connection import get_engine, ping as db_ping
from app.fetch import dart as dart_mod
@@ -104,6 +105,7 @@ app.include_router(predict_router)
app.include_router(metrics_router)
app.include_router(news_router)
app.include_router(markets_router)
+app.include_router(themes_router)
def _resolved_device() -> str:
diff --git a/backend/app/seed/themes.py b/backend/app/seed/themes.py
new file mode 100644
index 0000000..a25e12d
--- /dev/null
+++ b/backend/app/seed/themes.py
@@ -0,0 +1,231 @@
+"""테마/카테고리 → 종목코드 정적 매핑.
+
+토스 '주식 골라보기' 의 테마 분류와 비슷한 결을 흉내. 종목/테마 페어링은 공개
+정보 기준이며, 시장 사정에 따라 합병/분할/사명변경 등으로 stale 될 수 있다.
+KRX 정상화 + sector 컬럼 채우기가 끝나면 DB-driven 분류로 전환 가능.
+
+각 테마는 KRX_STATIC_MAJORS 안의 코드 위주 — symbols 테이블에 무조건 존재해
+검색/카드 표시가 깨지지 않음.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Theme:
+ slug: str
+ name: str
+ description: str
+ codes: tuple[str, ...]
+
+
+THEMES: tuple[Theme, ...] = (
+ Theme(
+ slug="semiconductor",
+ name="반도체",
+ description="메모리·파운드리·장비 핵심.",
+ codes=(
+ "005930", # 삼성전자
+ "000660", # SK하이닉스
+ "042700", # 한미반도체
+ "240810", # 원익IPS
+ "357180", # LX세미콘
+ "403870", # HPSP
+ "039030", # 이오테크닉스
+ "089030", # 테크윙
+ "095610", # 테스
+ "067310", # 하나마이크론
+ "000990", # DB하이텍
+ ),
+ ),
+ Theme(
+ slug="battery",
+ name="2차전지",
+ description="셀·소재·장비 전반.",
+ codes=(
+ "373220", # LG에너지솔루션
+ "006400", # 삼성SDI
+ "051910", # LG화학
+ "247540", # 에코프로비엠
+ "086520", # 에코프로
+ "066970", # 엘앤에프
+ "003670", # 포스코퓨처엠
+ "121600", # 나노신소재
+ "010130", # 고려아연
+ "213420", # 덕산네오룩스
+ ),
+ ),
+ Theme(
+ slug="bio",
+ name="바이오·헬스케어",
+ description="제약·바이오시밀러·CDMO.",
+ codes=(
+ "207940", # 삼성바이오로직스
+ "068270", # 셀트리온
+ "091990", # 셀트리온헬스케어
+ "196170", # 알테오젠
+ "145020", # 휴젤
+ "086900", # 메디톡스
+ "069620", # 대웅제약
+ "128940", # 한미약품
+ "000100", # 유한양행
+ "328130", # 루닛
+ "237690", # 에스티팜
+ "141080", # 리가켐바이오
+ "000250", # 삼천당제약
+ "028300", # HLB
+ "078160", # 메디포스트
+ "085660", # 차바이오텍
+ ),
+ ),
+ Theme(
+ slug="game",
+ name="게임",
+ description="모바일·PC·콘솔 게임사.",
+ codes=(
+ "035720", # 카카오 (게임즈 모회사)
+ "036570", # 엔씨소프트
+ "251270", # 넷마블
+ "263750", # 펄어비스
+ "293490", # 카카오게임즈
+ "112040", # 위메이드
+ "194700", # 네오위즈
+ "078340", # 컴투스
+ ),
+ ),
+ Theme(
+ slug="entertainment",
+ name="엔터테인먼트",
+ description="K-팝/엔터/플랫폼.",
+ codes=(
+ "352820", # 하이브
+ "035900", # JYP Ent.
+ "041510", # 에스엠
+ "122870", # 와이지엔터테인먼트
+ "067160", # 아프리카TV
+ "376300", # 디어유
+ "035760", # CJ ENM
+ "196300", # 쇼박스
+ ),
+ ),
+ Theme(
+ slug="auto",
+ name="자동차",
+ description="완성차·부품·타이어.",
+ codes=(
+ "005380", # 현대차
+ "000270", # 기아
+ "012330", # 현대모비스
+ "086280", # 현대글로비스
+ "161390", # 한국타이어앤테크놀로지
+ "000240", # 한국앤컴퍼니
+ ),
+ ),
+ Theme(
+ slug="finance",
+ name="금융",
+ description="은행·증권·보험·지주.",
+ codes=(
+ "105560", # KB금융
+ "055550", # 신한지주
+ "086790", # 하나금융지주
+ "316140", # 우리금융지주
+ "138040", # 메리츠금융지주
+ "175330", # JB금융지주
+ "138930", # BNK금융지주
+ "024110", # 기업은행
+ "032830", # 삼성생명
+ "088350", # 한화생명
+ "000810", # 삼성화재
+ "005830", # DB손해보험
+ "001450", # 현대해상
+ "071050", # 한국금융지주
+ ),
+ ),
+ Theme(
+ slug="shipbuilding-defense",
+ name="조선·방산",
+ description="조선·중공업·항공우주·방산.",
+ codes=(
+ "329180", # HD현대중공업
+ "009540", # HD한국조선해양
+ "267260", # HD현대일렉트릭
+ "010620", # 현대미포조선
+ "042660", # 한화오션
+ "010140", # 삼성중공업
+ "012450", # 한화에어로스페이스 (SEED)
+ "047810", # 한국항공우주
+ "064350", # 현대로템
+ "267250", # HD현대
+ ),
+ ),
+ Theme(
+ slug="platform-ai",
+ name="플랫폼·AI",
+ description="검색·메신저·콘텐츠·로봇.",
+ codes=(
+ "035420", # NAVER
+ "035720", # 카카오
+ "277810", # 레인보우로보틱스
+ "304100", # 솔트룩스
+ "388790", # 케이엠더블유
+ ),
+ ),
+ Theme(
+ slug="consumer-retail",
+ name="유통·소비재",
+ description="대형마트·백화점·식음료·뷰티.",
+ codes=(
+ "139480", # 이마트
+ "023530", # 롯데쇼핑
+ "069960", # 현대백화점
+ "004170", # 신세계
+ "282330", # BGF리테일
+ "007070", # GS리테일
+ "097950", # CJ제일제당
+ "271560", # 오리온
+ "005180", # 빙그레
+ "049770", # 동원F&B
+ "005300", # 롯데칠성
+ "000080", # 하이트진로
+ "002790", # 아모레G
+ "090430", # 아모레퍼시픽
+ "008770", # 호텔신라
+ ),
+ ),
+ Theme(
+ slug="energy-utility",
+ name="에너지·유틸리티",
+ description="전력·정유·가스·풍력.",
+ codes=(
+ "015760", # 한국전력공사
+ "017670", # SK텔레콤 (필수통신 묶음)
+ "030200", # KT
+ "010950", # S-Oil
+ "047050", # 포스코인터내셔널
+ "036460", # 한국가스공사 (SEED)
+ "034020", # 두산에너빌리티 (SEED)
+ "088980", # 맥쿼리인프라
+ ),
+ ),
+)
+
+
+def themes_index() -> list[dict]:
+ return [
+ {
+ "slug": t.slug,
+ "name": t.name,
+ "description": t.description,
+ "code_count": len(t.codes),
+ }
+ for t in THEMES
+ ]
+
+
+def theme_by_slug(slug: str) -> Theme | None:
+ for t in THEMES:
+ if t.slug == slug:
+ return t
+ return None
diff --git a/web/app/page.tsx b/web/app/page.tsx
index d0b51f6..3dd8ac3 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -1,6 +1,7 @@
import { MarketsOverview } from "../components/MarketsOverview";
import { SearchBox } from "../components/SearchBox";
import { SeedTiles } from "../components/SeedTiles";
+import { ThemeTiles } from "../components/ThemeTiles";
export default function HomePage() {
return (
@@ -19,6 +20,10 @@ export default function HomePage() {
{data.description}
+