From 04358469d18b2d78cde07285a29829ec6760e89a Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 01:27:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=ED=99=88=20SEED-10=20=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20+=20=EA=B0=80=EA=B2=A9=20=ED=97=A4=EB=A1=9C=20spark?= =?UTF-8?q?line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈 페이지: - 텍스트 카드를 SeedTiles 컴포넌트로 교체. 10개 병렬로 /api/chart 2일치 받아 현재가 + 전일대비 (절대값+%) 카드 그리드 (2/3/5 컬럼). - max-w 를 3xl → 5xl 로 확장하여 타일 5열 그리드 수용. 종목 페이지: - PriceHero 에 series prop 추가, 우측에 SVG sparkline (최근 30 종가) 표시. - 라인 색상은 전일대비 부호로 결정 (상승 빨강 / 하락 파랑). - lightweight-charts 인스턴스 추가 없이 인라인 SVG 만 사용 — 가벼움. verify: tsc/next lint clean. --- web/app/[code]/page.tsx | 14 ++-- web/app/page.tsx | 32 ++++----- web/components/PriceHero.tsx | 86 +++++++++++++++++++------ web/components/SeedTiles.tsx | 121 +++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 44 deletions(-) create mode 100644 web/components/SeedTiles.tsx diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index f40863f..7a32de5 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -66,17 +66,22 @@ export default function CodePage({ params }: { params: { code: string } }) { }; }, [code]); - // 현재가/전일가 — ohlcv 끝 두 점에서 산출. - const { current, prev, asOf } = useMemo(() => { - if (!chart || !chart.ohlcv.length) return { current: null, prev: null, asOf: null }; + // 현재가/전일가 + 헤더 sparkline 시리즈 — ohlcv 끝부분에서 산출. + const { current, prev, asOf, sparkSeries } = useMemo(() => { + if (!chart || !chart.ohlcv.length) + return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] }; const valid = chart.ohlcv.filter((p) => p.close != null); - if (!valid.length) return { current: null, prev: null, asOf: null }; + if (!valid.length) + return { current: null, prev: null, asOf: null, sparkSeries: [] as number[] }; const last = valid[valid.length - 1]; const prevPt = valid.length >= 2 ? valid[valid.length - 2] : null; + // 최근 30개 종가 (없으면 가용 전체). + const recent = valid.slice(-30).map((p) => p.close as number); return { current: last.close, prev: prevPt?.close ?? null, asOf: last.date, + sparkSeries: recent, }; }, [chart]); @@ -100,6 +105,7 @@ export default function CodePage({ params }: { params: { code: string } }) { current={current} prev={prev} asOf={asOf} + series={sparkSeries} />
diff --git a/web/app/page.tsx b/web/app/page.tsx index 05721c4..9155249 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,34 +1,28 @@ import { SearchBox } from "../components/SearchBox"; +import { SeedTiles } from "../components/SeedTiles"; export default function HomePage() { return ( -
-

- 종목 검색 -

+
+

종목 검색

종목을 골라 차트를 보고, 예상차트 보기 버튼으로 15일·30일·1년 예측 캔들을 같은 차트에 이어 붙입니다.

-
+
-
-
-
학습 대상 10종목
-
- 삼성전자, SK하이닉스, 에코프로비엠, 한미반도체, 두산에너빌리티, - 한화에어로스페이스, HD현대중공업, NAVER, KT&G, 한국가스공사 -
-
-
-
매칭/재학습
-
- 평일 16:30 KST 예측↔실제 매칭, 일요일 02:00 KST LGBM 재학습. - 앙상블은 Chronos(zero-shot) + LightGBM. -
+
+ +
+ +
+
매칭 · 재학습
+
+ 평일 16:30 KST 예측↔실제 매칭, 일요일 02:00 KST LGBM 재학습. + 앙상블은 Chronos(zero-shot) + LightGBM.
diff --git a/web/components/PriceHero.tsx b/web/components/PriceHero.tsx index c282227..b25a8db 100644 --- a/web/components/PriceHero.tsx +++ b/web/components/PriceHero.tsx @@ -1,7 +1,7 @@ "use client"; // 종목 페이지 상단 가격 헤더. -// 표시: 종목명 / (코드·시장) / 현재가 / 전일대비 (절대값 + 퍼센트). +// 표시: 종목명 / (코드·시장) / 현재가 / 전일대비 + 우측에 sparkline. // 한국 거래소 관행대로 상승=빨강, 하락=파랑. type Props = { @@ -11,9 +11,11 @@ type Props = { current: number | null; prev: number | null; asOf?: string | null; + // 헤더 우측 sparkline 용 종가 시계열 (오래된 → 최신 순). 비어 있으면 미표시. + series?: number[]; }; -export function PriceHero({ name, code, market, current, prev, asOf }: Props) { +export function PriceHero({ name, code, market, current, prev, asOf, series }: Props) { const change = current != null && prev != null ? current - prev : null; const pct = change != null && prev ? (change / prev) * 100 : null; const tone = @@ -25,28 +27,70 @@ export function PriceHero({ name, code, market, current, prev, asOf }: Props) { const arrow = change == null || change === 0 ? "—" : change > 0 ? "▲" : "▼"; return ( -
-
- {code} · {market} -
-

- {name} -

-
- - {current != null ? current.toLocaleString() : "-"} - - {change != null && pct != null && ( - - {arrow} {Math.abs(change).toLocaleString()}{" "} - ({pct >= 0 ? "+" : ""} - {pct.toFixed(2)}%) +
+
+
+ {code} · {market} +
+

+ {name} +

+
+ + {current != null ? current.toLocaleString() : "-"} - )} + {change != null && pct != null && ( + + {arrow} {Math.abs(change).toLocaleString()}{" "} + ({pct >= 0 ? "+" : ""} + {pct.toFixed(2)}%) + + )} +
+ {asOf &&
기준 {asOf}
}
- {asOf && ( -
기준 {asOf}
+ + {series && series.length >= 2 && ( + )}
); } + +// 가벼운 SVG sparkline. lightweight-charts 인스턴스를 또 만들 필요 없음. +function Sparkline({ values, stroke }: { values: number[]; stroke: string }) { + const W = 140; + const H = 44; + const min = Math.min(...values); + const max = Math.max(...values); + const span = max - min || 1; + const stepX = W / (values.length - 1); + const points = values + .map((v, i) => { + const x = (i * stepX).toFixed(1); + const y = (H - ((v - min) / span) * H).toFixed(1); + return `${x},${y}`; + }) + .join(" "); + return ( + + + + ); +} diff --git a/web/components/SeedTiles.tsx b/web/components/SeedTiles.tsx new file mode 100644 index 0000000..41e959f --- /dev/null +++ b/web/components/SeedTiles.tsx @@ -0,0 +1,121 @@ +"use client"; + +// 학습 대상 SEED 10종목 quick-access 타일. +// +// 토스 홈의 '인기 종목' 카드 톤. 각 타일이 자체적으로 /api/chart 를 2일치 받아 +// 현재가/전일대비 를 계산해서 표시. 10개 병렬 호출 — 백엔드는 캐시 친화적이므로 OK. +// +// 백엔드 신규 엔드포인트가 추가되면 (예: /api/markets/top-volume) 그쪽으로 갈아끼움. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api } from "../lib/api"; + +type Tile = { + code: string; + name: string; + market: string; + current: number | null; + prev: number | null; +}; + +const SEED_TICKERS: Array> = [ + { code: "005930", name: "삼성전자", market: "KOSPI" }, + { code: "000660", name: "SK하이닉스", market: "KOSPI" }, + { code: "247540", name: "에코프로비엠", market: "KOSDAQ" }, + { code: "042700", name: "한미반도체", market: "KOSPI" }, + { code: "034020", name: "두산에너빌리티", market: "KOSPI" }, + { code: "012450", name: "한화에어로스페이스", market: "KOSPI" }, + { code: "329180", name: "HD현대중공업", market: "KOSPI" }, + { code: "035420", name: "NAVER", market: "KOSPI" }, + { code: "033780", name: "KT&G", market: "KOSPI" }, + { code: "036460", name: "한국가스공사", market: "KOSPI" }, +]; + +export function SeedTiles() { + const [tiles, setTiles] = useState( + SEED_TICKERS.map((t) => ({ ...t, current: null, prev: null })), + ); + + useEffect(() => { + let alive = true; + // 10 병렬 fetch. 한 종목 실패해도 다른 타일은 살린다. + Promise.allSettled( + SEED_TICKERS.map(async (t) => { + const c = await api.getChart(t.code, 5, "1d"); + const valid = c.ohlcv.filter((p) => p.close != null); + const last = valid[valid.length - 1] ?? null; + const prev = valid.length >= 2 ? valid[valid.length - 2] : null; + return { + code: t.code, + name: t.name, + market: t.market, + current: last?.close ?? null, + prev: prev?.close ?? null, + }; + }), + ).then((settled) => { + if (!alive) return; + const next: Tile[] = settled.map((r, i) => { + if (r.status === "fulfilled") return r.value; + return { ...SEED_TICKERS[i], current: null, prev: null }; + }); + setTiles(next); + }); + return () => { + alive = false; + }; + }, []); + + return ( +
+
+
학습 대상 10종목
+
현재가 · 전일대비
+
+
+ {tiles.map((t) => ( + + ))} +
+
+ ); +} + +function TileCard({ t }: { t: Tile }) { + const change = + t.current != null && t.prev != null ? t.current - t.prev : null; + const pct = + change != null && t.prev != null && t.prev !== 0 + ? (change / t.prev) * 100 + : null; + const tone = + change == null || change === 0 + ? "text-zinc-400" + : change > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( + +
{t.name}
+
+ {t.code} · {t.market} +
+
+ {t.current != null + ? t.current.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : "—"} +
+
+ {change == null + ? " " + : `${change > 0 ? "+" : ""}${change.toLocaleString(undefined, { + maximumFractionDigits: 0, + })}${pct != null ? ` (${pct > 0 ? "+" : ""}${pct.toFixed(2)}%)` : ""}`} +
+ + ); +}