feat(compare): 종목 비교 페이지 (/compare)
- 최대 5종목 누적수익률(%) 한 차트 비교, 기간 1M/3M/6M/1Y - URL ?codes=A,B,C 양방향 동기화 (Suspense + useSearchParams) - 검색 칩으로 종목 추가, ✕ 로 제거. 종목별 최종 수익률 칩 표시 - TopNav 우측에 "비교" 링크
This commit is contained in:
414
web/app/compare/page.tsx
Normal file
414
web/app/compare/page.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// /compare?codes=005930,000660,...
|
||||||
|
// 최대 5 종목까지 같은 기간의 누적수익률(%)을 한 차트에 겹쳐서 비교.
|
||||||
|
// 토스의 "종목 비교" 화면 톤. 각 종목 첫 종가를 기준선(0%)으로 정규화.
|
||||||
|
//
|
||||||
|
// 구현:
|
||||||
|
// - URLSearchParams 의 codes (콤마구분) ↔ 내부 state 양방향
|
||||||
|
// - 기간 토글: 1M/3M/6M/1Y (각각 21/63/126/252 거래일)
|
||||||
|
// - 종목별 /api/chart 병렬 fetch → 누적수익률로 매핑 → lightweight-charts line 시리즈
|
||||||
|
// - 종목 추가는 헤더의 작은 검색바 (debounced)
|
||||||
|
// - 색상 팔레트 5색 순환
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
createChart,
|
||||||
|
type IChartApi,
|
||||||
|
type ISeriesApi,
|
||||||
|
type LineData,
|
||||||
|
type UTCTimestamp,
|
||||||
|
} from "lightweight-charts";
|
||||||
|
import { api, type Symbol as SymbolItem } from "../../lib/api";
|
||||||
|
|
||||||
|
const MAX_CODES = 5;
|
||||||
|
const PALETTE = ["#f43f5e", "#22d3ee", "#a78bfa", "#facc15", "#34d399"];
|
||||||
|
|
||||||
|
type Range = "1M" | "3M" | "6M" | "1Y";
|
||||||
|
const RANGE_DAYS: Record<Range, number> = { "1M": 21, "3M": 63, "6M": 126, "1Y": 252 };
|
||||||
|
|
||||||
|
type Series = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
points: { time: UTCTimestamp; value: number }[];
|
||||||
|
finalPct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isoToUtcTs(s: string): UTCTimestamp {
|
||||||
|
return (Date.UTC(
|
||||||
|
Number(s.slice(0, 4)),
|
||||||
|
Number(s.slice(5, 7)) - 1,
|
||||||
|
Number(s.slice(8, 10)),
|
||||||
|
) / 1000) as UTCTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComparePage() {
|
||||||
|
// useSearchParams 는 Suspense 안에서 안전. 내부 컴포넌트로 분리.
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CompareInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompareInner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const search = useSearchParams();
|
||||||
|
const codesParam = search.get("codes") ?? "";
|
||||||
|
const [codes, setCodes] = useState<string[]>(() => parseCodes(codesParam));
|
||||||
|
const [range, setRange] = useState<Range>("3M");
|
||||||
|
const [series, setSeries] = useState<Series[]>([]);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// URL ↔ state 동기화 — 외부에서 codes= 가 바뀌면 흡수.
|
||||||
|
useEffect(() => {
|
||||||
|
setCodes(parseCodes(codesParam));
|
||||||
|
}, [codesParam]);
|
||||||
|
|
||||||
|
// codes 변경을 URL 에 반영.
|
||||||
|
const writeUrl = (next: string[]) => {
|
||||||
|
const q = next.length ? `?codes=${next.join(",")}` : "";
|
||||||
|
router.replace(`/compare${q}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = (code: string, _name?: string) => {
|
||||||
|
if (!code) return;
|
||||||
|
if (codes.includes(code)) return;
|
||||||
|
if (codes.length >= MAX_CODES) return;
|
||||||
|
const next = [...codes, code];
|
||||||
|
setCodes(next);
|
||||||
|
writeUrl(next);
|
||||||
|
};
|
||||||
|
const remove = (code: string) => {
|
||||||
|
const next = codes.filter((c) => c !== code);
|
||||||
|
setCodes(next);
|
||||||
|
writeUrl(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시리즈 fetch.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setErr(null);
|
||||||
|
if (codes.length === 0) {
|
||||||
|
setSeries([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const days = RANGE_DAYS[range] + 8; // 비거래일 여유.
|
||||||
|
Promise.all(
|
||||||
|
codes.map(async (code) => {
|
||||||
|
const c = await api.getChart(code, days, "1d");
|
||||||
|
const valid = c.ohlcv.filter((p) => p.close != null && p.close > 0);
|
||||||
|
if (!valid.length) {
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
name: c.name,
|
||||||
|
points: [],
|
||||||
|
finalPct: null,
|
||||||
|
} as Series;
|
||||||
|
}
|
||||||
|
const base = valid[0].close as number;
|
||||||
|
const pts = valid.map((p) => ({
|
||||||
|
time: isoToUtcTs(p.date),
|
||||||
|
value: (((p.close as number) - base) / base) * 100,
|
||||||
|
}));
|
||||||
|
const final = pts[pts.length - 1].value;
|
||||||
|
return { code, name: c.name, points: pts, finalPct: final };
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) setSeries(r);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [codes, range]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||||
|
← 홈
|
||||||
|
</Link>
|
||||||
|
<span className="text-[11px] text-zinc-500">
|
||||||
|
{codes.length} / {MAX_CODES} 종목
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-50">종목 비교</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
기준일 종가를 0% 로 정규화한 누적수익률. 최대 {MAX_CODES} 종목까지 겹쳐서 보여줍니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||||
|
<RangeTabs value={range} onChange={setRange} />
|
||||||
|
<SearchAdd onPick={add} disabled={codes.length >= MAX_CODES} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{series.map((s, i) => (
|
||||||
|
<Chip
|
||||||
|
key={s.code}
|
||||||
|
color={PALETTE[i % PALETTE.length]}
|
||||||
|
name={s.name}
|
||||||
|
code={s.code}
|
||||||
|
pct={s.finalPct}
|
||||||
|
onRemove={() => remove(s.code)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{codes.length === 0 && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
상단의 검색바에서 종목을 골라 추가하세요.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="mt-4 rounded-md border border-rose-500/30 bg-rose-500/5 p-3 text-xs text-rose-200">
|
||||||
|
비교 데이터 로딩 실패: {err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<CompareChart series={series} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCodes(raw: string): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => /^[0-9A-Za-z]{4,12}$/.test(s)),
|
||||||
|
),
|
||||||
|
).slice(0, MAX_CODES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RangeTabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: Range;
|
||||||
|
onChange: (r: Range) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex overflow-hidden rounded-md border border-zinc-700 text-[11px]">
|
||||||
|
{(["1M", "3M", "6M", "1Y"] as Range[]).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(r)}
|
||||||
|
className={`px-2.5 py-1 ${
|
||||||
|
value === r
|
||||||
|
? "bg-zinc-800 text-zinc-100"
|
||||||
|
: "bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchAdd({
|
||||||
|
onPick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
onPick: (code: string, name: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [items, setItems] = useState<SymbolItem[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = q.trim();
|
||||||
|
if (!term) {
|
||||||
|
setItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.search(term, false, 6, false);
|
||||||
|
setItems(r.items);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(h);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (!wrapRef.current) return;
|
||||||
|
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="relative">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={disabled ? "최대치 도달" : "종목 추가 검색"}
|
||||||
|
className="w-56 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-100 outline-none focus:border-zinc-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{open && q.trim() && items.length > 0 && (
|
||||||
|
<ul className="absolute left-0 right-0 top-full z-10 mt-1 max-h-72 overflow-auto rounded-md border border-zinc-700 bg-zinc-950 shadow-xl">
|
||||||
|
{items.map((it) => (
|
||||||
|
<li key={it.code}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(it.code, it.name);
|
||||||
|
setQ("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-zinc-800/60"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-zinc-100">{it.name}</span>
|
||||||
|
<span className="shrink-0 text-zinc-500">
|
||||||
|
{it.code} · {it.market}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({
|
||||||
|
color,
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
pct,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
pct: number | null;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const tone = pct == null ? "text-zinc-400" : pct > 0 ? "text-rose-300" : pct < 0 ? "text-sky-300" : "text-zinc-300";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/50 px-2.5 py-1 text-xs">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
<span className="text-zinc-100">{name}</span>
|
||||||
|
<span className="text-[10px] text-zinc-500">{code}</span>
|
||||||
|
{pct != null && (
|
||||||
|
<span className={`tabular-nums ${tone}`}>
|
||||||
|
{pct >= 0 ? "+" : ""}
|
||||||
|
{pct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="ml-1 text-zinc-500 hover:text-rose-300"
|
||||||
|
aria-label="제거"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompareChart({ series }: { series: Series[] }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
const seriesRef = useRef<Map<string, ISeriesApi<"Line">>>(new Map());
|
||||||
|
|
||||||
|
// chart 생성/파괴.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const c = createChart(containerRef.current, {
|
||||||
|
layout: {
|
||||||
|
background: { color: "transparent" },
|
||||||
|
textColor: "#cbd5e1",
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: "#1f2937" },
|
||||||
|
horzLines: { color: "#1f2937" },
|
||||||
|
},
|
||||||
|
rightPriceScale: { borderColor: "#374151" },
|
||||||
|
timeScale: { borderColor: "#374151", timeVisible: false },
|
||||||
|
autoSize: true,
|
||||||
|
crosshair: { mode: 1 },
|
||||||
|
});
|
||||||
|
chartRef.current = c;
|
||||||
|
const map = seriesRef.current;
|
||||||
|
return () => {
|
||||||
|
c.remove();
|
||||||
|
chartRef.current = null;
|
||||||
|
map.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 시리즈 동기화.
|
||||||
|
useEffect(() => {
|
||||||
|
const c = chartRef.current;
|
||||||
|
if (!c) return;
|
||||||
|
const wanted = new Set(series.map((s) => s.code));
|
||||||
|
// 제거된 종목 unsubscribe.
|
||||||
|
for (const [code, s] of seriesRef.current) {
|
||||||
|
if (!wanted.has(code)) {
|
||||||
|
c.removeSeries(s);
|
||||||
|
seriesRef.current.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 추가/갱신.
|
||||||
|
series.forEach((s, i) => {
|
||||||
|
let line = seriesRef.current.get(s.code);
|
||||||
|
if (!line) {
|
||||||
|
line = c.addLineSeries({
|
||||||
|
color: PALETTE[i % PALETTE.length],
|
||||||
|
lineWidth: 2,
|
||||||
|
priceLineVisible: false,
|
||||||
|
lastValueVisible: false,
|
||||||
|
title: s.name,
|
||||||
|
});
|
||||||
|
seriesRef.current.set(s.code, line);
|
||||||
|
} else {
|
||||||
|
line.applyOptions({ color: PALETTE[i % PALETTE.length] });
|
||||||
|
}
|
||||||
|
const data: LineData[] = s.points.map((p) => ({ time: p.time, value: p.value }));
|
||||||
|
line.setData(data);
|
||||||
|
});
|
||||||
|
c.timeScale().fitContent();
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
const hasAny = useMemo(() => series.some((s) => s.points.length > 0), [series]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
||||||
|
<div className="h-[420px] w-full">
|
||||||
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
{!hasAny && (
|
||||||
|
<div className="px-2 py-2 text-[11px] text-zinc-500">
|
||||||
|
비교할 종목을 추가하면 차트가 그려집니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -151,6 +151,13 @@ export function TopNav() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/compare"
|
||||||
|
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
|
||||||
|
title="종목 비교"
|
||||||
|
>
|
||||||
|
비교
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/alerts"
|
href="/alerts"
|
||||||
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
|
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200"
|
||||||
|
|||||||
Reference in New Issue
Block a user