From ae4f07d6756b3d7b38131caa31087c7121e95c4d Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 14:46:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(home+search):=20KOSPI/KOSDAQ=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B9=B4=EB=93=9C=20+=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EA=B2=B0=EA=B3=BC=20=EB=AF=B8=EB=8B=88=20sparkline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sparkline 컴포넌트를 PriceHero 내부에서 web/components/Sparkline.tsx 로 분리·재사용 - /api/markets/indices: macro_daily 의 kospi/kosdaq N일 시계열 + 최신값/전일대비 - 홈 IndicesPanel: 두 인덱스 카드(현재값/등락/우측 sparkline) - /api/symbols/search?with_sparkline=true: 결과 한 번에 최근 30 종가 batch 조회 - SearchBox 결과 행에 mini sparkline + 현재가/등락률 인라인 표시 Co-Authored-By: Claude Opus 4.7 --- backend/app/api/markets.py | 54 ++++++++++++++++++++ backend/app/api/symbols.py | 61 ++++++++++++++++++---- web/app/page.tsx | 7 ++- web/components/IndicesPanel.tsx | 90 +++++++++++++++++++++++++++++++++ web/components/PriceHero.tsx | 37 +------------- web/components/SearchBox.tsx | 78 +++++++++++++++++++--------- web/components/Sparkline.tsx | 49 ++++++++++++++++++ web/lib/api.ts | 25 ++++++++- 8 files changed, 330 insertions(+), 71 deletions(-) create mode 100644 web/components/IndicesPanel.tsx create mode 100644 web/components/Sparkline.tsx diff --git a/backend/app/api/markets.py b/backend/app/api/markets.py index 48fc8a6..55965ce 100644 --- a/backend/app/api/markets.py +++ b/backend/app/api/markets.py @@ -118,6 +118,60 @@ def movers( } +@router.get("/indices") +def indices(days: int = Query(default=60, ge=2, le=400)) -> dict: + """KOSPI / KOSDAQ 지수 N일 종가 시계열. + + macro_daily 에 yfinance 가 채우는 'kospi','kosdaq' key 를 그대로 읽음. + 채워져 있지 않으면 series 가 빈 배열로 반환되어 프런트가 '데이터 준비 중' 안내. + """ + sql = text( + """ + SELECT date, key, value + FROM macro_daily + WHERE key IN ('kospi','kosdaq') + AND date >= (CURRENT_DATE - (:d || ' day')::interval) + ORDER BY date ASC + """ + ) + eng = get_engine() + with eng.connect() as conn: + rows = conn.execute(sql, {"d": days}).all() + + series: dict[str, list[dict]] = {"kospi": [], "kosdaq": []} + for r in rows: + d, k, v = r[0], r[1], r[2] + if v is None: + continue + series.setdefault(k, []).append({"date": str(d), "value": float(v)}) + + def _summary(pts: list[dict]) -> dict: + if not pts: + return {"latest": None, "prev": None, "pct_change": None} + latest = pts[-1]["value"] + prev = pts[-2]["value"] if len(pts) >= 2 else None + pct = ((latest - prev) / prev * 100) if (prev and prev > 0) else None + return {"latest": latest, "prev": prev, "pct_change": pct} + + return { + "days": days, + "indices": [ + { + "key": "kospi", + "name": "KOSPI", + "points": series["kospi"], + **_summary(series["kospi"]), + }, + { + "key": "kosdaq", + "name": "KOSDAQ", + "points": series["kosdaq"], + **_summary(series["kosdaq"]), + }, + ], + } + + @router.get("/related/{code}") def related(code: str, limit: int = Query(default=8, ge=1, le=30)) -> dict: """같은 market 에서 등락률 절대값 기준 가까운 종목 N 개. diff --git a/backend/app/api/symbols.py b/backend/app/api/symbols.py index 15417e1..a18123a 100644 --- a/backend/app/api/symbols.py +++ b/backend/app/api/symbols.py @@ -14,6 +14,10 @@ def search_symbols( q: str = Query(..., min_length=1, max_length=40, description="종목명 또는 코드 prefix/부분 일치"), limit: int = Query(default=20, ge=1, le=100), seed_only: bool = Query(default=False, description="true 면 학습/배치 대상 10종목만"), + with_sparkline: bool = Query( + default=False, + description="true 면 각 결과에 최근 30 종가 + 전일대비 동봉 (검색 결과 미니차트용)", + ), ) -> dict: """이름은 trigram + ILIKE, 코드는 prefix 매치. @@ -21,6 +25,11 @@ def search_symbols( 1) 코드가 정확히 같으면 가장 위 2) 이름 prefix 매치 3) 이름 부분 매치 (trigram similarity) + + with_sparkline=true 시: + - 매치된 코드들에 대해 ohlcv_daily 최근 30 거래일 종가 한 번에 조회 + - items[*].sparkline (list[float]) + items[*].close + items[*].pct_change 채움 + - 데이터 없는 종목은 sparkline=[], close=None """ q_norm = q.strip() if not q_norm: @@ -61,19 +70,51 @@ def search_symbols( "lim": limit, }, ).all() + + codes = [r[0] for r in rows] + spark_by_code: dict[str, list[float]] = {c: [] for c in codes} + if with_sparkline and codes: + # 최근 30 거래일 종가 — 종목별로 ORDER BY date ASC. + spark_rows = conn.execute( + text( + """ + SELECT code, date, close FROM ( + 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 + ) t + WHERE rn <= 30 + ORDER BY code, date ASC + """ + ), + {"codes": codes}, + ).all() + for code, _d, close in spark_rows: + spark_by_code.setdefault(code, []).append(float(close)) + + def _item(r) -> dict: + code = r[0] + pts = spark_by_code.get(code, []) if with_sparkline else None + out: dict[str, object] = { + "code": code, + "name": r[1], + "market": r[2], + "sector": r[3], + "is_seed": bool(r[4]), + } + if with_sparkline: + out["sparkline"] = pts or [] + out["close"] = pts[-1] if pts else None + out["pct_change"] = ( + (pts[-1] - pts[-2]) / pts[-2] * 100 if (pts and len(pts) >= 2 and pts[-2]) else None + ) + return out + return { "q": q_norm, "count": len(rows), - "items": [ - { - "code": r[0], - "name": r[1], - "market": r[2], - "sector": r[3], - "is_seed": bool(r[4]), - } - for r in rows - ], + "items": [_item(r) for r in rows], } diff --git a/web/app/page.tsx b/web/app/page.tsx index 3dd8ac3..445ca8d 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,3 +1,4 @@ +import { IndicesPanel } from "../components/IndicesPanel"; import { MarketsOverview } from "../components/MarketsOverview"; import { SearchBox } from "../components/SearchBox"; import { SeedTiles } from "../components/SeedTiles"; @@ -16,7 +17,11 @@ export default function HomePage() { -
+
+ +
+ +
diff --git a/web/components/IndicesPanel.tsx b/web/components/IndicesPanel.tsx new file mode 100644 index 0000000..57ae473 --- /dev/null +++ b/web/components/IndicesPanel.tsx @@ -0,0 +1,90 @@ +"use client"; + +// 홈 상단에 KOSPI / KOSDAQ 인덱스 두 칸. 우측엔 mini sparkline. +// macro_daily 가 아직 비어 있으면 안내 카드 한 줄. + +import { useEffect, useState } from "react"; +import { api, type IndicesResponse, type IndexSeries } from "../lib/api"; +import { Sparkline } from "./Sparkline"; + +export function IndicesPanel() { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + api + .indices(60) + .then((r) => { + if (alive) setData(r); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, []); + + if (err) + return ( +
+ 지수 로딩 실패: {err} +
+ ); + if (!data) + return ( +
+ 지수 로딩 중… +
+ ); + + const allEmpty = data.indices.every((s) => s.points.length === 0); + if (allEmpty) + return ( +
+ 지수 데이터 준비 중. macro_daily 가 채워지면 자동으로 표시됩니다. +
+ ); + + return ( +
+ {data.indices.map((s) => ( + + ))} +
+ ); +} + +function IndexCard({ s }: { s: IndexSeries }) { + const pct = s.pct_change; + const tone = + pct == null || pct === 0 + ? "text-zinc-400" + : pct > 0 + ? "text-rose-400" + : "text-sky-400"; + const stroke = pct != null && pct < 0 ? "#3b82f6" : "#ef4444"; + const values = s.points.map((p) => p.value); + + return ( +
+
+
{s.name}
+
+ {s.latest != null + ? s.latest.toLocaleString(undefined, { maximumFractionDigits: 2 }) + : "—"} +
+
+ {pct != null + ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` + : "—"} +
+
+ {values.length >= 2 && ( + + )} +
+ ); +} diff --git a/web/components/PriceHero.tsx b/web/components/PriceHero.tsx index b25a8db..6f291d7 100644 --- a/web/components/PriceHero.tsx +++ b/web/components/PriceHero.tsx @@ -4,6 +4,8 @@ // 표시: 종목명 / (코드·시장) / 현재가 / 전일대비 + 우측에 sparkline. // 한국 거래소 관행대로 상승=빨강, 하락=파랑. +import { Sparkline } from "./Sparkline"; + type Props = { name: string; code: string; @@ -59,38 +61,3 @@ export function PriceHero({ name, code, market, current, prev, asOf, series }: P
); } - -// 가벼운 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/SearchBox.tsx b/web/components/SearchBox.tsx index 07aeb32..8efbe94 100644 --- a/web/components/SearchBox.tsx +++ b/web/components/SearchBox.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { api, type Symbol } from "../lib/api"; +import { Sparkline } from "./Sparkline"; export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) { const [q, setQ] = useState(""); @@ -22,7 +23,8 @@ export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) { setErr(null); const handle = setTimeout(async () => { try { - const r = await api.search(term, seedOnly, 15); + // with_sparkline=true: 백엔드가 최근 30 종가 + close + pct_change 동봉. + const r = await api.search(term, seedOnly, 15, true); setItems(r.items); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); @@ -63,31 +65,61 @@ export function SearchBox({ autoFocus = false }: { autoFocus?: boolean }) { {items.length > 0 && (
    {items.map((it) => ( -
  • - -
    -
    - {it.name} - {it.is_seed && ( - - SEED - - )} -
    -
    - {it.code} · {it.market} - {it.sector ? ` · ${it.sector}` : ""} -
    -
    - - -
  • + ))}
)} ); } + +function SearchRow({ it }: { it: Symbol }) { + const pct = it.pct_change ?? null; + const tone = + pct == null || pct === 0 + ? "text-zinc-400" + : pct > 0 + ? "text-rose-400" + : "text-sky-400"; + const stroke = pct != null && pct < 0 ? "#3b82f6" : "#ef4444"; + const series = it.sparkline ?? []; + + return ( +
  • + +
    +
    + {it.name} + {it.is_seed && ( + + SEED + + )} +
    +
    + {it.code} · {it.market} + {it.sector ? ` · ${it.sector}` : ""} +
    +
    +
    + {series.length >= 2 && ( + + )} +
    +
    + {it.close != null + ? it.close.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : "—"} +
    +
    + {pct != null ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` : ""} +
    +
    +
    + +
  • + ); +} diff --git a/web/components/Sparkline.tsx b/web/components/Sparkline.tsx new file mode 100644 index 0000000..1db0f0b --- /dev/null +++ b/web/components/Sparkline.tsx @@ -0,0 +1,49 @@ +// 가벼운 SVG sparkline. lightweight-charts 인스턴스를 또 만들 필요 없음. +// 종가 시계열을 받아 polyline 으로 그린다. 색상은 호출부에서 결정 (KR 관행: 상승=빨강, 하락=파랑). + +type Props = { + values: number[]; + stroke: string; + width?: number; + height?: number; + className?: string; +}; + +export function Sparkline({ + values, + stroke, + width = 140, + height = 44, + className, +}: Props) { + if (values.length < 2) return null; + const min = Math.min(...values); + const max = Math.max(...values); + const span = max - min || 1; + const stepX = width / (values.length - 1); + const points = values + .map((v, i) => { + const x = (i * stepX).toFixed(1); + const y = (height - ((v - min) / span) * height).toFixed(1); + return `${x},${y}`; + }) + .join(" "); + return ( + + + + ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 430d0f9..36c0f47 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -33,6 +33,10 @@ export type Symbol = { market: string; sector: string | null; is_seed: boolean; + // with_sparkline=true 일 때만 채워짐. + sparkline?: number[]; + close?: number | null; + pct_change?: number | null; }; export type SymbolSearch = { @@ -205,6 +209,22 @@ export type ThemeDetailResponse = { items: Mover[]; }; +export type IndexPoint = { date: string; value: number }; + +export type IndexSeries = { + key: "kospi" | "kosdaq"; + name: string; + points: IndexPoint[]; + latest: number | null; + prev: number | null; + pct_change: number | null; +}; + +export type IndicesResponse = { + days: number; + indices: IndexSeries[]; +}; + async function getJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { ...init, @@ -222,9 +242,9 @@ async function getJson(path: string, init?: RequestInit): Promise { } export const api = { - search: (q: string, seedOnly = false, limit = 20) => + search: (q: string, seedOnly = false, limit = 20, withSparkline = false) => getJson( - `/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}`, + `/api/symbols/search?q=${encodeURIComponent(q)}&limit=${limit}&seed_only=${seedOnly}&with_sparkline=${withSparkline}`, ), getSymbol: (code: string) => getJson(`/api/symbols/${encodeURIComponent(code)}`), getChart: (code: string, days = 180, interval: ChartInterval = "1d") => @@ -258,6 +278,7 @@ export const api = { getJson( `/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`, ), + indices: (days = 60) => getJson(`/api/markets/indices?days=${days}`), themesIndex: () => getJson(`/api/themes`), themeDetail: (slug: string, limit = 30) => getJson(