feat(peers): 동종업종(테마) 비교 패널
- backend/app/seed/themes.py: themes_for_code(code) 헬퍼 추가 (코드 → 소속 테마들)
- backend/app/api/themes.py: GET /api/themes/peer/{code} 신규
· 종목이 속한 모든 테마의 코드 합집합을 peer 풀로
· 단일 SQL (ROW_NUMBER OVER PARTITION BY code) 로 21영업일 누적수익률 일괄 계산
· 본인 / peer 평균 / 상·하위 5종목 반환
- web/lib/api.ts: PeerCompare 타입 + api.peerCompare()
- web/components/PeerComparePanel.tsx: 테마 태그 + 3-칸 요약 (본인/평균/격차) + 상·하위 5
- [code]/page.tsx 에 PeriodReturns 아래 부착. 테마 무소속 종목은 패널 자동 숨김.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ from fastapi import APIRouter, HTTPException, Query
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.db.connection import get_engine
|
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"])
|
router = APIRouter(prefix="/api/themes", tags=["themes"])
|
||||||
|
|
||||||
@@ -27,6 +27,112 @@ def list_themes() -> dict:
|
|||||||
return {"items": themes_index(), "total": len(THEMES)}
|
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}")
|
@router.get("/{slug}")
|
||||||
def get_theme(slug: str, limit: int = Query(default=30, ge=1, le=100)) -> dict:
|
def get_theme(slug: str, limit: int = Query(default=30, ge=1, le=100)) -> dict:
|
||||||
th = theme_by_slug(slug)
|
th = theme_by_slug(slug)
|
||||||
|
|||||||
@@ -229,3 +229,8 @@ def theme_by_slug(slug: str) -> Theme | None:
|
|||||||
if t.slug == slug:
|
if t.slug == slug:
|
||||||
return t
|
return t
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def themes_for_code(code: str) -> list[Theme]:
|
||||||
|
"""주어진 종목 코드가 속한 테마들. 한 종목이 여러 테마에 동시 등재될 수 있음."""
|
||||||
|
return [t for t in THEMES if code in t.codes]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
import { CompositeScoreCard } from "../../components/CompositeScoreCard";
|
||||||
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
import { DisclosuresPanel } from "../../components/DisclosuresPanel";
|
||||||
import { MetricsPanel } from "../../components/MetricsPanel";
|
import { MetricsPanel } from "../../components/MetricsPanel";
|
||||||
|
import { PeerComparePanel } from "../../components/PeerComparePanel";
|
||||||
import { NewsList } from "../../components/NewsList";
|
import { NewsList } from "../../components/NewsList";
|
||||||
import { PeriodReturns } from "../../components/PeriodReturns";
|
import { PeriodReturns } from "../../components/PeriodReturns";
|
||||||
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs";
|
||||||
@@ -147,6 +148,9 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<PeriodReturns ohlcv={chart.ohlcv} />
|
<PeriodReturns ohlcv={chart.ohlcv} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<PeerComparePanel code={code} />
|
||||||
|
</div>
|
||||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||||
<TradingValuePanel data={chart.trading_value} />
|
<TradingValuePanel data={chart.trading_value} />
|
||||||
<MetricsPanel code={code} />
|
<MetricsPanel code={code} />
|
||||||
|
|||||||
163
web/components/PeerComparePanel.tsx
Normal file
163
web/components/PeerComparePanel.tsx
Normal file
@@ -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<PeerCompareResponse | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(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 (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
|
||||||
|
동종업종 로딩 실패: {err}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
||||||
|
동종업종 로딩 중…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 테마 무소속 → 패널 숨김
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
||||||
|
<div className="mb-3 flex items-baseline justify-between">
|
||||||
|
<div className="text-sm font-medium text-zinc-200">동종업종 비교</div>
|
||||||
|
<div className="text-[11px] text-zinc-500">
|
||||||
|
최근 {data.window}거래일 · peer {data.peer_count ?? 0}종목
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
|
{data.themes.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.slug}
|
||||||
|
href={`/themes/${t.slug}`}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[11px] text-zinc-300 hover:border-zinc-500"
|
||||||
|
>
|
||||||
|
#{t.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-3 gap-2 text-center">
|
||||||
|
<CompareTile label="이 종목" pct={self} bold />
|
||||||
|
<CompareTile label="업종 평균" pct={avg} />
|
||||||
|
<CompareTile label="격차" pct={diff} sign />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<PeerList title="상위 5종목" items={data.top} />
|
||||||
|
<PeerList title="하위 5종목" items={data.bottom} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/30 px-2 py-2">
|
||||||
|
<div className="text-[11px] text-zinc-500">{label}</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1 tabular-nums ${tone} ${
|
||||||
|
bold ? "text-base font-semibold" : "text-sm font-medium"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeerList({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: PeerCompareItem[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[11px] text-zinc-500">{title}</div>
|
||||||
|
<ul className="divide-y divide-zinc-800 rounded-md border border-zinc-800">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<li className="px-2 py-2 text-xs text-zinc-500">데이터 없음</li>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<li key={it.code}>
|
||||||
|
<Link
|
||||||
|
href={`/${it.code}`}
|
||||||
|
className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs hover:bg-zinc-800/50"
|
||||||
|
>
|
||||||
|
<span className="truncate text-zinc-200">{it.name}</span>
|
||||||
|
<span className={`shrink-0 tabular-nums ${tone}`}>
|
||||||
|
{it.pct_change == null
|
||||||
|
? "—"
|
||||||
|
: `${it.pct_change >= 0 ? "+" : ""}${it.pct_change.toFixed(2)}%`}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -209,6 +209,27 @@ export type ThemeDetailResponse = {
|
|||||||
items: Mover[];
|
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 IndexPoint = { date: string; value: number };
|
||||||
|
|
||||||
export type IndexSeries = {
|
export type IndexSeries = {
|
||||||
@@ -284,4 +305,8 @@ export const api = {
|
|||||||
getJson<ThemeDetailResponse>(
|
getJson<ThemeDetailResponse>(
|
||||||
`/api/themes/${encodeURIComponent(slug)}?limit=${limit}`,
|
`/api/themes/${encodeURIComponent(slug)}?limit=${limit}`,
|
||||||
),
|
),
|
||||||
|
peerCompare: (code: string, window = 21, limit = 5) =>
|
||||||
|
getJson<PeerCompareResponse>(
|
||||||
|
`/api/themes/peer/${encodeURIComponent(code)}?window=${window}&limit=${limit}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user