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 — 시세 표시는 영향 없음.
235 lines
7.7 KiB
TypeScript
235 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
|
|
//
|
|
// 표시 항목:
|
|
// - 오늘: 시가 / 고가 / 저가 / 거래량
|
|
// - 52주: 최고 / 최저 (1Y 일봉에서 계산) + 게이지
|
|
// - 펀더멘털: 시가총액 / PER / PBR / EPS / BPS / 배당수익률 (pykrx KRX 스냅샷)
|
|
//
|
|
// 차트 폴링과 독립적으로 1Y 일봉 + 펀더멘털 스냅샷을 한 번씩 받는다.
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
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) => {
|
|
if (alive) setChart(c);
|
|
})
|
|
.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;
|
|
};
|
|
}, [code]);
|
|
|
|
const stats = useMemo(() => {
|
|
if (!chart) return null;
|
|
const points = chart.ohlcv.filter((p) => p.close != null);
|
|
if (!points.length) return null;
|
|
const last = points[points.length - 1];
|
|
|
|
let yearHigh = -Infinity;
|
|
let yearLow = Infinity;
|
|
for (const p of points) {
|
|
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: yh,
|
|
yearLow: yl,
|
|
yearPos: pos,
|
|
};
|
|
}, [chart]);
|
|
|
|
if (err) {
|
|
return (
|
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-red-400">
|
|
시세 정보 로딩 실패: {err}
|
|
</div>
|
|
);
|
|
}
|
|
if (!stats) {
|
|
return (
|
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4 text-xs text-zinc-500">
|
|
시세 정보 로딩 중…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-md border border-zinc-800 bg-zinc-900/40 p-4">
|
|
<div className="mb-3 text-sm font-medium text-zinc-200">시세 정보</div>
|
|
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
|
<Row label="시가" value={fmt(stats.open)} />
|
|
<Row label="고가" value={fmt(stats.high)} tone="up" />
|
|
<Row label="저가" value={fmt(stats.low)} tone="down" />
|
|
<Row label="거래량" value={fmtCompact(stats.volume)} />
|
|
<Row label="52주 최고" value={fmt(stats.yearHigh)} tone="up" />
|
|
<Row label="52주 최저" value={fmt(stats.yearLow)} tone="down" />
|
|
</dl>
|
|
{stats.yearPos != null && (
|
|
<YearRangeBar
|
|
low={stats.yearLow}
|
|
high={stats.yearHigh}
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 52주 범위 내 현재가 위치 — 토스 종목 상세에 있는 가로 게이지를 흉내냄.
|
|
function YearRangeBar({
|
|
low,
|
|
high,
|
|
pos,
|
|
}: {
|
|
low: number | null;
|
|
high: number | null;
|
|
pos: number;
|
|
}) {
|
|
const pct = pos * 100;
|
|
return (
|
|
<div className="mt-4 border-t border-zinc-800 pt-3">
|
|
<div className="mb-1.5 flex items-center justify-between text-[10px] text-zinc-500">
|
|
<span>52주 위치</span>
|
|
<span className="tabular-nums text-zinc-300">{pct.toFixed(0)}%</span>
|
|
</div>
|
|
<div className="relative h-1.5 w-full rounded-full bg-zinc-800">
|
|
<div
|
|
className="absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-sky-500/70 via-amber-400/70 to-rose-500/70"
|
|
style={{ width: "100%" }}
|
|
/>
|
|
<div
|
|
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-zinc-950 bg-zinc-100 shadow"
|
|
style={{ left: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-1 flex justify-between text-[10px] tabular-nums text-zinc-500">
|
|
<span>{fmt(low)}</span>
|
|
<span>{fmt(high)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({
|
|
label,
|
|
value,
|
|
tone,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
tone?: "up" | "down";
|
|
}) {
|
|
const toneCls =
|
|
tone === "up" ? "text-rose-300" : tone === "down" ? "text-sky-300" : "text-zinc-100";
|
|
return (
|
|
<>
|
|
<dt className="text-zinc-500">{label}</dt>
|
|
<dd className={`text-right tabular-nums ${toneCls}`}>{value}</dd>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function fmt(n: number | null | undefined): string {
|
|
if (n == null || !Number.isFinite(n)) return "-";
|
|
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
|
}
|
|
|
|
function fmtCompact(n: number | null | undefined): string {
|
|
if (n == null || !Number.isFinite(n)) return "-";
|
|
if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`;
|
|
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)}%`;
|
|
}
|