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() { +
+ +
+
diff --git a/web/app/themes/[slug]/page.tsx b/web/app/themes/[slug]/page.tsx new file mode 100644 index 0000000..153ce0c --- /dev/null +++ b/web/app/themes/[slug]/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +// 테마 상세 페이지: /themes/{slug}. +// 카드 그리드로 해당 테마 종목을 나열. close/pct_change 없으면 자리만 표시. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api, type ThemeDetailResponse } from "../../../lib/api"; + +export default function ThemePage({ params }: { params: { slug: string } }) { + const { slug } = params; + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + setErr(null); + setData(null); + api + .themeDetail(slug, 60) + .then((r) => { + if (alive) setData(r); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, [slug]); + + return ( +
+
+ + ← 홈 + +
+ + {err &&
테마 로딩 실패: {err}
} + {!data && !err &&
테마 로딩 중…
} + + {data && ( + <> +

{data.name}

+

{data.description}

+
+ {data.items.length} 종목 +
+ +
+ {data.items.map((m) => { + const pct = m.pct_change; + const tone = + pct == null || pct === 0 + ? "text-zinc-400" + : pct > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( + +
+
+ {m.name} +
+
+ {m.market ?? "—"} +
+
+
+ + {m.close != null + ? m.close.toLocaleString(undefined, { + maximumFractionDigits: 0, + }) + : "—"} + + + {pct != null + ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` + : ""} + +
+
{m.code}
+ + ); + })} +
+ + )} +
+ ); +} diff --git a/web/components/ThemeTiles.tsx b/web/components/ThemeTiles.tsx new file mode 100644 index 0000000..c6572b8 --- /dev/null +++ b/web/components/ThemeTiles.tsx @@ -0,0 +1,69 @@ +"use client"; + +// 홈에 들어가는 '테마로 골라보기' 그리드. +// /api/themes 인덱스를 받아 카드로 펼치고, 카드 클릭 시 /themes/{slug} 로 이동. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api, type ThemesIndexResponse } from "../lib/api"; + +export function ThemeTiles() { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + api + .themesIndex() + .then((r) => { + if (alive) setData(r); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, []); + + if (err) + return ( +
+ 테마 로딩 실패: {err} +
+ ); + if (!data) + return ( +
+ 테마 로딩 중… +
+ ); + + return ( +
+
+

테마로 골라보기

+ {data.total} 카테고리 +
+
+ {data.items.map((t) => ( + +
+ {t.name} +
+
+ {t.description} +
+
+ {t.code_count} 종목 +
+ + ))} +
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 037a6a9..430d0f9 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -186,6 +186,25 @@ export type RelatedResponse = { items: Mover[]; }; +export type ThemeIndex = { + slug: string; + name: string; + description: string; + code_count: number; +}; + +export type ThemesIndexResponse = { + items: ThemeIndex[]; + total: number; +}; + +export type ThemeDetailResponse = { + slug: string; + name: string; + description: string; + items: Mover[]; +}; + async function getJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { ...init, @@ -239,4 +258,9 @@ export const api = { getJson( `/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`, ), + themesIndex: () => getJson(`/api/themes`), + themeDetail: (slug: string, limit = 30) => + getJson( + `/api/themes/${encodeURIComponent(slug)}?limit=${limit}`, + ), };