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(