feat(fundamentals): 시가총액·PER·PBR·EPS·BPS·배당 — pykrx 스냅샷 + SymbolSidebar

backend:
- fetch/fundamentals.py: get_fundamentals(code) — pykrx 의 get_market_cap +
  get_market_fundamental 을 같은 영업일로 정렬해서 호출. 토/일/공휴일이면 KRX 가 빈
  응답을 줘서 최대 7일까지 역순 backoff. (code, today) 인메모리 daily 캐시로 중복 호출 방지.
- api/fundamentals.py: GET /api/fundamentals/{code} — 없는 코드 404, KRX 가 데이터를
  안 주는 케이스는 status="no_data" + null 들. 200 응답으로 단순화 (호출자 분기 편의).
- main.py: fundamentals_router 등록.

frontend:
- lib/api.ts: FundamentalsResponse 타입 + api.fundamentals(code).
- components/SymbolSidebar.tsx: 1Y 차트와 별도로 fundamentals 호출. ok 일 때만
  '기업 지표' 섹션 노출 — 시가총액(조/억) / 상장주식수 / PER / PBR / EPS / BPS /
  배당수익률(%) / 주당배당금. PER·PBR 음수면 '적자' 표기 (이익 음수 → 멀티플 무의미).
- 기존 시세/52주 게이지 코멘트에 fundamentals 추가됨을 반영.

pykrx 종목당 호출 1~2초 → 첫 페이지 로딩이 살짝 느려질 수 있으나 daily 캐시로
재방문은 즉시. 펀더멘털 실패는 silent — 시세 표시는 영향 없음.
This commit is contained in:
claude-owner
2026-05-28 19:25:53 +09:00
parent 59dc8c4e7a
commit a79e784550
5 changed files with 274 additions and 5 deletions

View File

@@ -2,24 +2,26 @@
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
//
// 표시 항목 (백엔드가 안정적으로 제공하는 값으로 한정):
// 표시 항목:
// - 오늘: 시가 / 고가 / 저가 / 거래량
// - 52주: 최고 / 최저 (1Y 일봉에서 계산)
// - 52주: 최고 / 최저 (1Y 일봉에서 계산) + 게이지
// - 펀더멘털: 시가총액 / PER / PBR / EPS / BPS / 배당수익률 (pykrx KRX 스냅샷)
//
// 백엔드에 PER/EPS/시총 데이터가 아직 없으므로 그 항목들은 의도적으로 제외.
// 차트 폴링과 독립적으로 1Y 일봉을 한 번만 받아 52주 범위만 계산한다.
// 차트 폴링과 독립적으로 1Y 일봉 + 펀더멘털 스냅샷을 한 번씩 받는다.
import { useEffect, useMemo, useState } from "react";
import { api, type ChartPayload } from "../lib/api";
import { api, type ChartPayload, type FundamentalsResponse } from "../lib/api";
export function SymbolSidebar({ code }: { code: string }) {
const [chart, setChart] = useState<ChartPayload | null>(null);
const [fund, setFund] = useState<FundamentalsResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setErr(null);
setChart(null);
setFund(null);
api
.getChart(code, 365, "1d")
.then((c) => {
@@ -28,6 +30,15 @@ export function SymbolSidebar({ code }: { code: string }) {
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : String(e));
});
// 펀더멘털은 차트와 독립 — 실패해도 시세 표시는 유지.
api
.fundamentals(code)
.then((f) => {
if (alive) setFund(f);
})
.catch(() => {
// 무시: 펀더멘털 없으면 그 섹션만 숨김.
});
return () => {
alive = false;
};
@@ -97,6 +108,32 @@ export function SymbolSidebar({ code }: { code: string }) {
pos={stats.yearPos}
/>
)}
{fund && fund.status === "ok" && <FundamentalsBlock f={fund} />}
</div>
);
}
// 시가총액 / PER / PBR / EPS / BPS / 배당수익률. KRX 스냅샷 일자 표시.
// 값 미존재 (e.g. 신규 상장으로 EPS 안 나옴) 항목은 자동으로 "-".
function FundamentalsBlock({ f }: { f: FundamentalsResponse }) {
return (
<div className="mt-4 border-t border-zinc-800 pt-3">
<div className="mb-2 flex items-center justify-between text-[10px] text-zinc-500">
<span> </span>
{f.snapshot_date && (
<span className="tabular-nums">{f.snapshot_date} </span>
)}
</div>
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<Row label="시가총액" value={fmtKrw(f.market_cap)} />
<Row label="상장주식수" value={fmtShares(f.shares_outstanding)} />
<Row label="PER" value={fmtMultiple(f.per)} />
<Row label="PBR" value={fmtMultiple(f.pbr)} />
<Row label="EPS" value={fmt(f.eps)} />
<Row label="BPS" value={fmt(f.bps)} />
<Row label="배당수익률" value={fmtPct(f.dvd_yield)} />
<Row label="주당배당금" value={fmt(f.div)} />
</dl>
</div>
);
}
@@ -166,3 +203,32 @@ function fmtCompact(n: number | null | undefined): string {
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}`;
return n.toLocaleString();
}
// 시가총액은 단위가 매우 큼 — 조/억 까지 단순화.
function fmtKrw(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n >= 1e12) return `${(n / 1e12).toFixed(2)}`;
if (n >= 1e8) return `${(n / 1e8).toFixed(0)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}`;
return n.toLocaleString();
}
function fmtShares(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n >= 1e8) return `${(n / 1e8).toFixed(2)}`;
if (n >= 1e4) return `${(n / 1e4).toFixed(0)}`;
return n.toLocaleString();
}
// PER/PBR — 음수면 "적자" 표기 (이익 음수 = PER 의미 없음).
function fmtMultiple(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n <= 0) return "적자";
return `${n.toFixed(2)}`;
}
function fmtPct(n: number | null | undefined): string {
if (n == null || !Number.isFinite(n)) return "-";
if (n === 0) return "-";
return `${n.toFixed(2)}%`;
}

View File

@@ -254,6 +254,22 @@ export type OrderbookLevel = {
qty: number;
};
export type FundamentalsResponse = {
code: string;
name: string;
market: string;
status: "ok" | "no_data";
snapshot_date: string | null;
market_cap: number | null;
shares_outstanding: number | null;
per: number | null;
pbr: number | null;
eps: number | null;
bps: number | null;
div: number | null; // 주당배당금 (원)
dvd_yield: number | null; // 배당수익률 (%)
};
export type OrderbookResponse = {
code: string;
name: string;
@@ -334,4 +350,6 @@ export const api = {
),
orderbook: (code: string) =>
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
fundamentals: (code: string) =>
getJson<FundamentalsResponse>(`/api/fundamentals/${encodeURIComponent(code)}`),
};