- 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>
164 lines
4.7 KiB
TypeScript
164 lines
4.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|