diff --git a/backend/app/api/themes.py b/backend/app/api/themes.py index 3c84f2f..3ee2d74 100644 --- a/backend/app/api/themes.py +++ b/backend/app/api/themes.py @@ -17,7 +17,7 @@ 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 +from app.seed.themes import THEMES, theme_by_slug, themes_for_code, themes_index router = APIRouter(prefix="/api/themes", tags=["themes"]) @@ -27,6 +27,112 @@ def list_themes() -> dict: return {"items": themes_index(), "total": len(THEMES)} +@router.get("/peer/{code}") +def peer_compare( + code: str, + window: int = Query(default=21, ge=2, le=252), + limit: int = Query(default=5, ge=1, le=20), +) -> dict: + """동일 테마(들) 내 동종목 비교. + + - 대상 종목이 속한 모든 테마의 코드 합집합 (자기 자신 제외) 을 peer 풀로 잡고, + 각 종목의 최근 거래일 종가 / window 영업일 전 종가로 누적수익률을 구한다. + - peer 평균, 대상 종목 값, 상/하위 N 개 반환. + - 테마 무소속이면 themes=[] 빈 응답 (404 아님 — UI 가 그냥 패널을 숨기게). + """ + themes = themes_for_code(code) + if not themes: + return { + "code": code, + "window": window, + "themes": [], + "self_pct": None, + "peer_avg_pct": None, + "top": [], + "bottom": [], + } + + # 테마 합집합 코드 풀 (자기 자신 제외) + peer_codes: set[str] = set() + for t in themes: + peer_codes.update(t.codes) + peer_codes.discard(code) + pool = [code, *sorted(peer_codes)] + + sql = text( + """ + WITH ranked AS ( + SELECT code, date, close, + ROW_NUMBER() OVER (PARTITION BY code ORDER BY date DESC) AS rn + FROM ohlcv_daily + WHERE code = ANY(:codes) AND close IS NOT NULL + ), + latest AS ( + SELECT code, close AS close_now, date AS date_now FROM ranked WHERE rn = 1 + ), + base AS ( + SELECT code, close AS close_base, date AS date_base FROM ranked WHERE rn = :w + ) + SELECT s.code, s.name, s.market, + l.close_now, b.close_base, + CASE WHEN b.close_base > 0 + THEN (l.close_now - b.close_base) / b.close_base * 100 + ELSE NULL + END AS pct + FROM symbols s + LEFT JOIN latest l ON l.code = s.code + LEFT JOIN base b ON b.code = s.code + WHERE s.code = ANY(:codes) + """ + ) + + eng = get_engine() + with eng.connect() as conn: + rows = conn.execute(sql, {"codes": pool, "w": window}).all() + + by_code: dict[str, dict] = {} + for r in rows: + by_code[r[0]] = { + "code": r[0], + "name": r[1], + "market": r[2], + "close": float(r[3]) if r[3] is not None else None, + "base_close": float(r[4]) if r[4] is not None else None, + "pct_change": float(r[5]) if r[5] is not None else None, + } + + self_row = by_code.get(code) + self_pct = self_row["pct_change"] if self_row else None + + peers = [ + v for k, v in by_code.items() if k != code and v["pct_change"] is not None + ] + if peers: + peer_avg_pct = sum(p["pct_change"] for p in peers) / len(peers) + peers_sorted = sorted(peers, key=lambda x: x["pct_change"], reverse=True) + top = peers_sorted[:limit] + bottom = peers_sorted[-limit:][::-1] + else: + peer_avg_pct = None + top = [] + bottom = [] + + return { + "code": code, + "name": self_row["name"] if self_row else code, + "window": window, + "themes": [ + {"slug": t.slug, "name": t.name, "code_count": len(t.codes)} + for t in themes + ], + "self_pct": self_pct, + "peer_avg_pct": peer_avg_pct, + "peer_count": len(peers), + "top": top, + "bottom": bottom, + } + + @router.get("/{slug}") def get_theme(slug: str, limit: int = Query(default=30, ge=1, le=100)) -> dict: th = theme_by_slug(slug) diff --git a/backend/app/seed/themes.py b/backend/app/seed/themes.py index a25e12d..f2d70d2 100644 --- a/backend/app/seed/themes.py +++ b/backend/app/seed/themes.py @@ -229,3 +229,8 @@ def theme_by_slug(slug: str) -> Theme | None: if t.slug == slug: return t return None + + +def themes_for_code(code: str) -> list[Theme]: + """주어진 종목 코드가 속한 테마들. 한 종목이 여러 테마에 동시 등재될 수 있음.""" + return [t for t in THEMES if code in t.codes] diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 13525cd..59c04c4 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { CompositeScoreCard } from "../../components/CompositeScoreCard"; import { DisclosuresPanel } from "../../components/DisclosuresPanel"; import { MetricsPanel } from "../../components/MetricsPanel"; +import { PeerComparePanel } from "../../components/PeerComparePanel"; import { NewsList } from "../../components/NewsList"; import { PeriodReturns } from "../../components/PeriodReturns"; import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs"; @@ -147,6 +148,9 @@ export default function CodePage({ params }: { params: { code: string } }) {