diff --git a/backend/app/api/markets.py b/backend/app/api/markets.py new file mode 100644 index 0000000..48fc8a6 --- /dev/null +++ b/backend/app/api/markets.py @@ -0,0 +1,161 @@ +"""시장 overview API. + +GET /api/markets/movers + ?market=KOSPI|KOSDAQ|ALL (기본 ALL) + ?limit=10 (각 카테고리 별 상위 N) + +응답 (3 카테고리): + - gainers: 등락률 상위 (전일종가 대비 상승%) + - losers : 등락률 하위 (하락%) + - volume : 거래량 상위 (당일 volume) + +ohlcv_daily 에서 가장 최근 2 거래일을 잡아 비교한다. 데이터가 한 종목당 +2 일치 이상 없을 때는 그 종목은 자연 누락된다. + +GET /api/markets/related/{code} + ?limit=8 + 같은 market 에서 등락률이 비슷한 (또는 가장 활발한) 종목 N 개. + sector 컬럼은 비어있는 경우가 많아, market 동질성만으로 묶는다. +""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import text + +from app.db.connection import get_engine + +router = APIRouter(prefix="/api/markets", tags=["markets"]) + + +def _movers_sql(where_market: str) -> str: + """ohlcv_daily 에서 종목별 최신 2일 비교 + 카테고리 분류. + + market filter 는 symbols 테이블에서 적용. WITH … AS 안에 :market 바인딩이 + 있는 단일 쿼리. + """ + return f""" + WITH last_dates AS ( + SELECT DISTINCT date + FROM ohlcv_daily + ORDER BY date DESC + LIMIT 2 + ), + ordered AS ( + SELECT MAX(date) AS d0, MIN(date) AS d1 FROM last_dates + ), + symb AS ( + SELECT code, name, market FROM symbols + WHERE active = TRUE {where_market} + ), + latest AS ( + SELECT o.code, o.close AS close0, o.volume AS volume0 + FROM ohlcv_daily o, ordered + WHERE o.date = ordered.d0 + ), + prev AS ( + SELECT o.code, o.close AS close1 + FROM ohlcv_daily o, ordered + WHERE o.date = ordered.d1 + ) + SELECT s.code, s.name, s.market, + l.close0 AS close, + p.close1 AS prev_close, + l.volume0 AS volume, + CASE WHEN p.close1 > 0 + THEN (l.close0 - p.close1) / p.close1 * 100 + ELSE NULL + END AS pct_change + FROM symb s + JOIN latest l ON l.code = s.code + JOIN prev p ON p.code = s.code + WHERE l.close0 IS NOT NULL AND p.close1 IS NOT NULL + """ + + +@router.get("/movers") +def movers( + market: str = Query(default="ALL", pattern="^(ALL|KOSPI|KOSDAQ)$"), + limit: int = Query(default=10, ge=1, le=50), +) -> dict: + where_market = "" if market == "ALL" else "AND market = :market" + params: dict[str, object] = {"lim": limit} + if market != "ALL": + params["market"] = market + + eng = get_engine() + base_sql = _movers_sql(where_market) + with eng.connect() as conn: + gainers = conn.execute( + text(base_sql + " ORDER BY pct_change DESC NULLS LAST LIMIT :lim"), + params, + ).all() + losers = conn.execute( + text(base_sql + " ORDER BY pct_change ASC NULLS LAST LIMIT :lim"), + params, + ).all() + by_volume = conn.execute( + text(base_sql + " ORDER BY volume DESC NULLS LAST LIMIT :lim"), + params, + ).all() + + def _row(r) -> dict: + return { + "code": r[0], + "name": r[1], + "market": r[2], + "close": float(r[3]) if r[3] is not None else None, + "prev_close": float(r[4]) if r[4] is not None else None, + "volume": int(r[5]) if r[5] is not None else None, + "pct_change": float(r[6]) if r[6] is not None else None, + } + + return { + "market": market, + "limit": limit, + "gainers": [_row(r) for r in gainers], + "losers": [_row(r) for r in losers], + "by_volume": [_row(r) for r in by_volume], + } + + +@router.get("/related/{code}") +def related(code: str, limit: int = Query(default=8, ge=1, le=30)) -> dict: + """같은 market 에서 등락률 절대값 기준 가까운 종목 N 개. + + sector 컬럼은 시드 단계에서 NULL 인 경우가 많아 market 만으로 동질성을 잡는다. + 백엔드가 sector 분류를 채우게 되면 같은 sector 우선 정렬로 강화할 수 있음. + """ + eng = get_engine() + with eng.connect() as conn: + target = conn.execute( + text("SELECT market FROM symbols WHERE code = :c"), + {"c": code}, + ).first() + if not target: + raise HTTPException(status_code=404, detail=f"unknown code: {code}") + market = target[0] + + rows = conn.execute( + text( + _movers_sql("AND market = :market AND code <> :c") + + " ORDER BY volume DESC NULLS LAST LIMIT :lim" + ), + {"market": market, "c": code, "lim": limit}, + ).all() + + return { + "code": code, + "market": market, + "items": [ + { + "code": r[0], + "name": r[1], + "market": r[2], + "close": float(r[3]) if r[3] is not None else None, + "prev_close": float(r[4]) if r[4] is not None else None, + "volume": int(r[5]) if r[5] is not None else None, + "pct_change": float(r[6]) if r[6] is not None else None, + } + for r in rows + ], + } diff --git a/backend/app/main.py b/backend/app/main.py index b07f1b8..a012651 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from app.api.chart import router as chart_router +from app.api.markets import router as markets_router from app.api.metrics import router as metrics_router from app.api.news import router as news_router from app.api.predict import router as predict_router @@ -102,6 +103,7 @@ app.include_router(chart_router) app.include_router(predict_router) app.include_router(metrics_router) app.include_router(news_router) +app.include_router(markets_router) def _resolved_device() -> str: diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index 7a32de5..b3c74e0 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -7,6 +7,7 @@ import { NewsList } from "../../components/NewsList"; import { PeriodTabs, periodSpec, type Period } from "../../components/PeriodTabs"; import { PredictionPanel } from "../../components/PredictionPanel"; import { PriceHero } from "../../components/PriceHero"; +import { RelatedStocks } from "../../components/RelatedStocks"; import { StockChart } from "../../components/StockChart"; import { SymbolSidebar } from "../../components/SymbolSidebar"; import { api, type ChartPayload, type LatestPredictionResponse } from "../../lib/api"; @@ -119,7 +120,10 @@ export default function CodePage({ params }: { params: { code: string } }) { - +
+ + +
diff --git a/web/app/page.tsx b/web/app/page.tsx index 9155249..d0b51f6 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,3 +1,4 @@ +import { MarketsOverview } from "../components/MarketsOverview"; import { SearchBox } from "../components/SearchBox"; import { SeedTiles } from "../components/SeedTiles"; @@ -18,6 +19,10 @@ export default function HomePage() {
+
+ +
+
매칭 · 재학습
diff --git a/web/components/MarketsOverview.tsx b/web/components/MarketsOverview.tsx new file mode 100644 index 0000000..495ca79 --- /dev/null +++ b/web/components/MarketsOverview.tsx @@ -0,0 +1,147 @@ +"use client"; + +// 시장 overview: 등락 상위 / 등락 하위 / 거래량 상위 3 컬럼. +// /api/markets/movers 응답을 그대로 표 형태로 렌더. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api, type Mover, type MoversResponse } from "../lib/api"; + +export function MarketsOverview() { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + api + .movers("ALL", 10) + .then((r) => { + if (alive) setData(r); + }) + .catch((e) => { + if (alive) setErr(e instanceof Error ? e.message : String(e)); + }); + return () => { + alive = false; + }; + }, []); + + if (err) + return ( +
+ 시장 overview 로딩 실패: {err} +
+ ); + if (!data) + return ( +
+ 시장 overview 로딩 중… +
+ ); + + // 아직 ohlcv_daily 가 비어 있으면 모든 리스트가 빈 배열로 옴 — 안내만. + const empty = + !data.gainers.length && !data.losers.length && !data.by_volume.length; + if (empty) + return ( +
+ 시장 데이터 준비 중. ohlcv_daily 가 채워지면 자동으로 표시됩니다. +
+ ); + + return ( +
+ + + +
+ ); +} + +function MoverList({ + title, + items, + showVolume = false, +}: { + title: string; + items: Mover[]; + showVolume?: boolean; +}) { + return ( +
+
{title}
+ {items.length === 0 ? ( +
데이터 없음
+ ) : ( +
    + {items.map((m, i) => ( + + ))} +
+ )} +
+ ); +} + +function MoverRow({ + rank, + m, + showVolume, +}: { + rank: number; + m: Mover; + showVolume: boolean; +}) { + const pct = m.pct_change; + const tone = + pct == null || pct === 0 + ? "text-zinc-400" + : pct > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( +
  • + +
    + {rank} + {m.name} +
    +
    + {showVolume ? ( + <> +
    {fmtVol(m.volume)}
    +
    + {pct != null + ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` + : ""} +
    + + ) : ( + <> +
    + {m.close != null + ? m.close.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : "—"} +
    +
    + {pct != null + ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` + : ""} +
    + + )} +
    + +
  • + ); +} + +function fmtVol(n: number | null): string { + if (n == null) return "—"; + if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`; + if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`; + return n.toLocaleString(); +} diff --git a/web/components/RelatedStocks.tsx b/web/components/RelatedStocks.tsx new file mode 100644 index 0000000..46e69a5 --- /dev/null +++ b/web/components/RelatedStocks.tsx @@ -0,0 +1,91 @@ +"use client"; + +// 같은 시장 내 거래량/유동성 기준 관련 종목 8개. +// /api/markets/related/{code} 응답을 좁은 list 카드로 렌더. + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api, type RelatedResponse } from "../lib/api"; + +export function RelatedStocks({ code }: { code: string }) { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + setErr(null); + setData(null); + api + .related(code, 8) + .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 ( +
    + 관련 종목 로딩 실패: {err} +
    + ); + if (!data) + return ( +
    + 관련 종목 로딩 중… +
    + ); + if (!data.items.length) + return ( +
    + 같은 시장({data.market}) 데이터가 아직 부족합니다. +
    + ); + + return ( +
    +
    +
    관련 종목
    +
    {data.market} · 거래량 기준
    +
    +
      + {data.items.map((m) => { + const pct = m.pct_change; + const tone = + pct == null || pct === 0 + ? "text-zinc-400" + : pct > 0 + ? "text-rose-400" + : "text-sky-400"; + return ( +
    • + + {m.name} + + + {m.close != null + ? m.close.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : "—"} + + + {pct != null + ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` + : ""} + + + +
    • + ); + })} +
    +
    + ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index fd3610a..037a6a9 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -162,6 +162,30 @@ export type NewsResponse = { items: NewsItem[]; }; +export type Mover = { + code: string; + name: string; + market: string; + close: number | null; + prev_close: number | null; + volume: number | null; + pct_change: number | null; +}; + +export type MoversResponse = { + market: string; + limit: number; + gainers: Mover[]; + losers: Mover[]; + by_volume: Mover[]; +}; + +export type RelatedResponse = { + code: string; + market: string; + items: Mover[]; +}; + async function getJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { ...init, @@ -209,4 +233,10 @@ export const api = { source ? `&source=${encodeURIComponent(source)}` : "" }`, ), + movers: (market: "ALL" | "KOSPI" | "KOSDAQ" = "ALL", limit = 10) => + getJson(`/api/markets/movers?market=${market}&limit=${limit}`), + related: (code: string, limit = 8) => + getJson( + `/api/markets/related/${encodeURIComponent(code)}?limit=${limit}`, + ), };