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 } }) {
+
+ +
diff --git a/web/components/PeerComparePanel.tsx b/web/components/PeerComparePanel.tsx new file mode 100644 index 0000000..8ac06dc --- /dev/null +++ b/web/components/PeerComparePanel.tsx @@ -0,0 +1,163 @@ +"use client"; + +// 동종업종(테마) 비교 패널 — 토스의 "동일업종 비교" 톤. +// /api/themes/peer/{code} 한 번 호출로 (테마 라벨, 본인 수익률, 평균, 상/하위 N) 모두 받음. +// 테마 무소속 종목은 패널을 통째로 숨김. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api, type PeerCompareItem, type PeerCompareResponse } from "../lib/api"; + +export function PeerComparePanel({ code }: { code: string }) { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + api + .peerCompare(code, 21, 5) + .then((r) => { + if (alive) setData(r); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, [code]); + + if (err) { + return ( +
+ 동종업종 로딩 실패: {err} +
+ ); + } + if (!data) { + return ( +
+ 동종업종 로딩 중… +
+ ); + } + // 테마 무소속 → 패널 숨김 + if (data.themes.length === 0) return null; + + const self = data.self_pct; + const avg = data.peer_avg_pct; + const diff = self != null && avg != null ? self - avg : null; + + return ( +
+
+
동종업종 비교
+
+ 최근 {data.window}거래일 · peer {data.peer_count ?? 0}종목 +
+
+ +
+ {data.themes.map((t) => ( + + #{t.name} + + ))} +
+ +
+ + + +
+ +
+ + +
+
+ ); +} + +function CompareTile({ + label, + pct, + bold = false, + sign = false, +}: { + label: string; + pct: number | null; + bold?: boolean; + sign?: boolean; +}) { + const tone = + pct == null || pct === 0 + ? "text-zinc-400" + : pct > 0 + ? "text-rose-400" + : "text-sky-400"; + const text = + pct == null + ? "—" + : sign + ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%p` + : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`; + return ( +
+
{label}
+
+ {text} +
+
+ ); +} + +function PeerList({ + title, + items, +}: { + title: string; + items: PeerCompareItem[]; +}) { + return ( +
+
{title}
+
    + {items.length === 0 && ( +
  • 데이터 없음
  • + )} + {items.map((it) => { + const tone = + it.pct_change == null || it.pct_change === 0 + ? "text-zinc-400" + : it.pct_change > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( +
  • + + {it.name} + + {it.pct_change == null + ? "—" + : `${it.pct_change >= 0 ? "+" : ""}${it.pct_change.toFixed(2)}%`} + + +
  • + ); + })} +
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 36c0f47..12a793f 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -209,6 +209,27 @@ export type ThemeDetailResponse = { items: Mover[]; }; +export type PeerCompareItem = { + code: string; + name: string; + market: string | null; + close: number | null; + base_close: number | null; + pct_change: number | null; +}; + +export type PeerCompareResponse = { + code: string; + name?: string; + window: number; + themes: { slug: string; name: string; code_count: number }[]; + self_pct: number | null; + peer_avg_pct: number | null; + peer_count?: number; + top: PeerCompareItem[]; + bottom: PeerCompareItem[]; +}; + export type IndexPoint = { date: string; value: number }; export type IndexSeries = { @@ -284,4 +305,8 @@ export const api = { getJson( `/api/themes/${encodeURIComponent(slug)}?limit=${limit}`, ), + peerCompare: (code: string, window = 21, limit = 5) => + getJson( + `/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`, + ), };