From 645e93e7bf0f854cbd38509ed6b1526e19d3c6f7 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 15:21:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(market):=20=EA=B1=B0=EB=9E=98=EB=8C=80?= =?UTF-8?q?=EA=B8=88=20=EC=83=81=EC=9C=84=20+=2052=EC=A3=BC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EA=B2=8C=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/markets/movers: by_trading_value (close*volume DESC) 카테고리 추가 - MarketsOverview: 4 컬럼 그리드 (등락 상위/하위/거래대금/거래량), 거래대금은 억·조 표기 - SymbolSidebar: 52주 범위 내 현재가 위치 게이지 (그라데이션 바 + 화이트 점) --- backend/app/api/markets.py | 18 +++++-- web/components/MarketsOverview.tsx | 78 ++++++++++++++++++------------ web/components/SymbolSidebar.tsx | 55 ++++++++++++++++++++- web/lib/api.ts | 2 + 4 files changed, 114 insertions(+), 39 deletions(-) diff --git a/backend/app/api/markets.py b/backend/app/api/markets.py index 55965ce..5a0eedf 100644 --- a/backend/app/api/markets.py +++ b/backend/app/api/markets.py @@ -4,10 +4,11 @@ GET /api/markets/movers ?market=KOSPI|KOSDAQ|ALL (기본 ALL) ?limit=10 (각 카테고리 별 상위 N) -응답 (3 카테고리): - - gainers: 등락률 상위 (전일종가 대비 상승%) - - losers : 등락률 하위 (하락%) - - volume : 거래량 상위 (당일 volume) +응답 (4 카테고리): + - gainers : 등락률 상위 (전일종가 대비 상승%) + - losers : 등락률 하위 (하락%) + - by_volume : 거래량 상위 (당일 volume) + - by_trading_value: 거래대금 상위 (close × volume, 원 단위) ohlcv_daily 에서 가장 최근 2 거래일을 잡아 비교한다. 데이터가 한 종목당 2 일치 이상 없을 때는 그 종목은 자연 누락된다. @@ -64,7 +65,8 @@ def _movers_sql(where_market: str) -> str: CASE WHEN p.close1 > 0 THEN (l.close0 - p.close1) / p.close1 * 100 ELSE NULL - END AS pct_change + END AS pct_change, + (l.close0 * l.volume0) AS trading_value FROM symb s JOIN latest l ON l.code = s.code JOIN prev p ON p.code = s.code @@ -97,6 +99,10 @@ def movers( text(base_sql + " ORDER BY volume DESC NULLS LAST LIMIT :lim"), params, ).all() + by_trading_value = conn.execute( + text(base_sql + " ORDER BY trading_value DESC NULLS LAST LIMIT :lim"), + params, + ).all() def _row(r) -> dict: return { @@ -107,6 +113,7 @@ def movers( "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, + "trading_value": float(r[7]) if r[7] is not None else None, } return { @@ -115,6 +122,7 @@ def movers( "gainers": [_row(r) for r in gainers], "losers": [_row(r) for r in losers], "by_volume": [_row(r) for r in by_volume], + "by_trading_value": [_row(r) for r in by_trading_value], } diff --git a/web/components/MarketsOverview.tsx b/web/components/MarketsOverview.tsx index 495ca79..eabcb15 100644 --- a/web/components/MarketsOverview.tsx +++ b/web/components/MarketsOverview.tsx @@ -1,7 +1,8 @@ "use client"; -// 시장 overview: 등락 상위 / 등락 하위 / 거래량 상위 3 컬럼. +// 시장 overview: 등락 상위 / 등락 하위 / 거래대금 상위 / 거래량 상위 4 컬럼. // /api/markets/movers 응답을 그대로 표 형태로 렌더. +// 거래대금 = close × volume, 단위 억원. 토스 톤의 핵심 랭킹. import Link from "next/link"; import { useEffect, useState } from "react"; @@ -41,7 +42,10 @@ export function MarketsOverview() { // 아직 ohlcv_daily 가 비어 있으면 모든 리스트가 빈 배열로 옴 — 안내만. const empty = - !data.gainers.length && !data.losers.length && !data.by_volume.length; + !data.gainers.length && + !data.losers.length && + !data.by_volume.length && + !data.by_trading_value.length; if (empty) return (
@@ -50,22 +54,29 @@ export function MarketsOverview() { ); return ( -
+
- + +
); } +type Metric = "price" | "volume" | "trading_value"; + function MoverList({ title, items, - showVolume = false, + metric = "price", }: { title: string; items: Mover[]; - showVolume?: boolean; + metric?: Metric; }) { return (
@@ -75,7 +86,7 @@ function MoverList({ ) : (
    {items.map((m, i) => ( - + ))}
)} @@ -86,11 +97,11 @@ function MoverList({ function MoverRow({ rank, m, - showVolume, + metric, }: { rank: number; m: Mover; - showVolume: boolean; + metric: Metric; }) { const pct = m.pct_change; const tone = @@ -99,6 +110,19 @@ function MoverRow({ : pct > 0 ? "text-rose-400" : "text-sky-400"; + + let primary: string; + if (metric === "volume") { + primary = fmtVol(m.volume); + } else if (metric === "trading_value") { + primary = fmtKrw(m.trading_value ?? null); + } else { + primary = + m.close != null + ? m.close.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : "—"; + } + return (
  • {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)}%` - : ""} -
    - - )} +
    {primary}
    +
    + {pct != null ? `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%` : ""} +
    @@ -145,3 +150,12 @@ function fmtVol(n: number | null): string { if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`; return n.toLocaleString(); } + +// 거래대금 원 → 억/조 표기. 토스에서 가장 흔한 단위. +function fmtKrw(n: number | null): string { + if (n == null) return "—"; + if (n >= 1e12) return `${(n / 1e12).toFixed(2)}조`; + if (n >= 1e8) return `${Math.round(n / 1e8).toLocaleString()}억`; + if (n >= 1e4) return `${(n / 1e4).toFixed(0)}만`; + return n.toLocaleString(); +} diff --git a/web/components/SymbolSidebar.tsx b/web/components/SymbolSidebar.tsx index d6d112e..2fc54d9 100644 --- a/web/components/SymbolSidebar.tsx +++ b/web/components/SymbolSidebar.tsx @@ -45,13 +45,22 @@ export function SymbolSidebar({ code }: { code: string }) { if (p.high != null && p.high > yearHigh) yearHigh = p.high; if (p.low != null && p.low < yearLow) yearLow = p.low; } + const yh = yearHigh === -Infinity ? null : yearHigh; + const yl = yearLow === Infinity ? null : yearLow; + // 52주 위치 (0~1). 종가 기준, 분모 0 방어. + let pos: number | null = null; + if (yh != null && yl != null && yh > yl && last.close != null) { + pos = Math.min(1, Math.max(0, (last.close - yl) / (yh - yl))); + } return { open: last.open, high: last.high, low: last.low, + close: last.close, volume: last.volume, - yearHigh: yearHigh === -Infinity ? null : yearHigh, - yearLow: yearLow === Infinity ? null : yearLow, + yearHigh: yh, + yearLow: yl, + yearPos: pos, }; }, [chart]); @@ -81,6 +90,48 @@ export function SymbolSidebar({ code }: { code: string }) { + {stats.yearPos != null && ( + + )} +
    + ); +} + +// 52주 범위 내 현재가 위치 — 토스 종목 상세에 있는 가로 게이지를 흉내냄. +function YearRangeBar({ + low, + high, + pos, +}: { + low: number | null; + high: number | null; + pos: number; +}) { + const pct = pos * 100; + return ( +
    +
    + 52주 위치 + {pct.toFixed(0)}% +
    +
    +
    +
    +
    +
    + {fmt(low)} + {fmt(high)} +
    ); } diff --git a/web/lib/api.ts b/web/lib/api.ts index 12a793f..b1ccba7 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -174,6 +174,7 @@ export type Mover = { prev_close: number | null; volume: number | null; pct_change: number | null; + trading_value?: number | null; }; export type MoversResponse = { @@ -182,6 +183,7 @@ export type MoversResponse = { gainers: Mover[]; losers: Mover[]; by_volume: Mover[]; + by_trading_value: Mover[]; }; export type RelatedResponse = {