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:
63
backend/app/api/fundamentals.py
Normal file
63
backend/app/api/fundamentals.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""기업 펀더멘털 (시총/PER/PBR/EPS/BPS/배당) API.
|
||||||
|
|
||||||
|
pykrx 한 번 호출 = 종목당 ~1~2초. 인메모리 daily 캐시(get_fundamentals) 가 처리.
|
||||||
|
SkippedMissingKey 같은 거 없음 — pykrx 는 무인증 KRX 스크래핑.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db.connection import get_engine
|
||||||
|
from app.fetch.fundamentals import get_fundamentals
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/fundamentals", tags=["fundamentals"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{code}")
|
||||||
|
def code_fundamentals(code: str) -> dict:
|
||||||
|
"""code 의 최신 영업일 fundamentals 스냅샷."""
|
||||||
|
eng = get_engine()
|
||||||
|
with eng.connect() as conn:
|
||||||
|
sym = conn.execute(
|
||||||
|
text("SELECT code, name, market FROM symbols WHERE code = :c"),
|
||||||
|
{"c": code},
|
||||||
|
).first()
|
||||||
|
if not sym:
|
||||||
|
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
|
||||||
|
|
||||||
|
f = get_fundamentals(code)
|
||||||
|
if f is None:
|
||||||
|
return {
|
||||||
|
"code": sym[0],
|
||||||
|
"name": sym[1],
|
||||||
|
"market": sym[2],
|
||||||
|
"status": "no_data",
|
||||||
|
"snapshot_date": None,
|
||||||
|
"market_cap": None,
|
||||||
|
"shares_outstanding": None,
|
||||||
|
"per": None,
|
||||||
|
"pbr": None,
|
||||||
|
"eps": None,
|
||||||
|
"bps": None,
|
||||||
|
"div": None,
|
||||||
|
"dvd_yield": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": sym[0],
|
||||||
|
"name": sym[1],
|
||||||
|
"market": sym[2],
|
||||||
|
"status": "ok",
|
||||||
|
"snapshot_date": f.snapshot_date.isoformat(),
|
||||||
|
"market_cap": f.market_cap,
|
||||||
|
"shares_outstanding": f.shares_outstanding,
|
||||||
|
"per": f.per,
|
||||||
|
"pbr": f.pbr,
|
||||||
|
"eps": f.eps,
|
||||||
|
"bps": f.bps,
|
||||||
|
"div": f.div,
|
||||||
|
"dvd_yield": f.dvd_yield,
|
||||||
|
}
|
||||||
120
backend/app/fetch/fundamentals.py
Normal file
120
backend/app/fetch/fundamentals.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""기업 펀더멘털 (시가총액 / PER / PBR / EPS / BPS / 배당수익률) — pykrx 기반.
|
||||||
|
|
||||||
|
KRX 가 일 단위로 산정한 멀티플 + 시총 스냅샷을 가져온다. pykrx 호출은 KRX 페이지 스크래핑
|
||||||
|
이라 종목당 1~2초가 든다. 같은 (code, snapshot_date) 는 인메모리 캐시로 중복 호출을 막는다.
|
||||||
|
|
||||||
|
API 호출이 자주 들어와도 같은 날 같은 종목은 한 번만 KRX 를 때린다. 컨테이너 재기동 시
|
||||||
|
캐시는 비어 다시 채워진다 (디스크 캐시까지는 안 만든다 — KRX 는 인증/쿼터가 없어서
|
||||||
|
재발급 비용이 없음).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 한국 시장 마지막 영업일 후보 — 토/일이면 금요일로, 공휴일은 KRX 가 빈 응답을 줘서
|
||||||
|
# 한 단계씩 거꾸로 시도 (최대 7일). 7일 모두 비면 fundamentals 데이터 없음.
|
||||||
|
_MAX_BACKOFF_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Fundamentals:
|
||||||
|
code: str
|
||||||
|
snapshot_date: date
|
||||||
|
market_cap: float | None # 원
|
||||||
|
shares_outstanding: int | None # 상장주식수
|
||||||
|
per: float | None
|
||||||
|
pbr: float | None
|
||||||
|
eps: float | None
|
||||||
|
bps: float | None
|
||||||
|
div: float | None # 배당금 (원)
|
||||||
|
dvd_yield: float | None # 배당수익률 (%)
|
||||||
|
|
||||||
|
|
||||||
|
# (code, today) → Fundamentals. None 도 캐시해서 KRX 없는 종목 반복 호출 방지.
|
||||||
|
_cache: dict[tuple[str, date], Fundamentals | None] = {}
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_pykrx(d: date) -> str:
|
||||||
|
return d.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_one(code: str, target: date) -> Fundamentals | None:
|
||||||
|
"""target 일 기준 KRX fundamentals 스냅샷. KRX 가 그 날 빈 응답이면 직전 영업일로 backoff.
|
||||||
|
|
||||||
|
KRX 는 토/일/공휴일에 데이터를 안 주므로 target 부터 최대 _MAX_BACKOFF_DAYS 일 이전까지
|
||||||
|
역순으로 시도. 시총 / 멀티플 두 호출을 같은 target_date 로 정렬.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from pykrx import stock as krx
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pykrx unavailable")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur = target
|
||||||
|
for _ in range(_MAX_BACKOFF_DAYS):
|
||||||
|
ds = _to_pykrx(cur)
|
||||||
|
try:
|
||||||
|
cap_df = krx.get_market_cap(ds, ds, code)
|
||||||
|
fund_df = krx.get_market_fundamental(ds, ds, code)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("pykrx fundamentals failed code=%s date=%s", code, ds)
|
||||||
|
return None
|
||||||
|
|
||||||
|
cap_ok = cap_df is not None and not cap_df.empty
|
||||||
|
fund_ok = fund_df is not None and not fund_df.empty
|
||||||
|
if cap_ok or fund_ok:
|
||||||
|
# 한쪽이라도 있으면 그 날짜로 채택. 결측은 None.
|
||||||
|
def _val(df: Any, key: str) -> float | None:
|
||||||
|
if df is None or df.empty:
|
||||||
|
return None
|
||||||
|
v = df.iloc[0].get(key)
|
||||||
|
try:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
f = float(v)
|
||||||
|
if f != f: # NaN
|
||||||
|
return None
|
||||||
|
return f
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# pykrx fundamental 컬럼: ['BPS', 'PER', 'PBR', 'EPS', 'DIV', 'DPS']
|
||||||
|
# DIV = 배당수익률 (%), DPS = 주당배당금 (원).
|
||||||
|
mc = _val(cap_df, "시가총액")
|
||||||
|
shares_f = _val(cap_df, "상장주식수")
|
||||||
|
shares: int | None = int(shares_f) if shares_f is not None else None
|
||||||
|
return Fundamentals(
|
||||||
|
code=code,
|
||||||
|
snapshot_date=cur,
|
||||||
|
market_cap=mc,
|
||||||
|
shares_outstanding=shares,
|
||||||
|
per=_val(fund_df, "PER"),
|
||||||
|
pbr=_val(fund_df, "PBR"),
|
||||||
|
eps=_val(fund_df, "EPS"),
|
||||||
|
bps=_val(fund_df, "BPS"),
|
||||||
|
div=_val(fund_df, "DPS"),
|
||||||
|
dvd_yield=_val(fund_df, "DIV"),
|
||||||
|
)
|
||||||
|
cur = cur - timedelta(days=1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_fundamentals(code: str, *, today: date | None = None) -> Fundamentals | None:
|
||||||
|
"""code 의 최신 영업일 fundamentals. 같은 (code, today) 는 인메모리 캐시."""
|
||||||
|
t = today or date.today()
|
||||||
|
key = (code, t)
|
||||||
|
with _cache_lock:
|
||||||
|
if key in _cache:
|
||||||
|
return _cache[key]
|
||||||
|
val = _fetch_one(code, t)
|
||||||
|
with _cache_lock:
|
||||||
|
_cache[key] = val
|
||||||
|
return val
|
||||||
@@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.api.chart import router as chart_router
|
from app.api.chart import router as chart_router
|
||||||
|
from app.api.fundamentals import router as fundamentals_router
|
||||||
from app.api.markets import router as markets_router
|
from app.api.markets import router as markets_router
|
||||||
from app.api.metrics import router as metrics_router
|
from app.api.metrics import router as metrics_router
|
||||||
from app.api.news import router as news_router
|
from app.api.news import router as news_router
|
||||||
@@ -108,6 +109,7 @@ app.include_router(news_router)
|
|||||||
app.include_router(markets_router)
|
app.include_router(markets_router)
|
||||||
app.include_router(themes_router)
|
app.include_router(themes_router)
|
||||||
app.include_router(orderbook_router)
|
app.include_router(orderbook_router)
|
||||||
|
app.include_router(fundamentals_router)
|
||||||
|
|
||||||
|
|
||||||
def _resolved_device() -> str:
|
def _resolved_device() -> str:
|
||||||
|
|||||||
@@ -2,24 +2,26 @@
|
|||||||
|
|
||||||
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
|
// 토스증권 우측 정보 사이드바를 흉내낸 카드 묶음.
|
||||||
//
|
//
|
||||||
// 표시 항목 (백엔드가 안정적으로 제공하는 값으로 한정):
|
// 표시 항목:
|
||||||
// - 오늘: 시가 / 고가 / 저가 / 거래량
|
// - 오늘: 시가 / 고가 / 저가 / 거래량
|
||||||
// - 52주: 최고 / 최저 (1Y 일봉에서 계산)
|
// - 52주: 최고 / 최저 (1Y 일봉에서 계산) + 게이지
|
||||||
|
// - 펀더멘털: 시가총액 / PER / PBR / EPS / BPS / 배당수익률 (pykrx KRX 스냅샷)
|
||||||
//
|
//
|
||||||
// 백엔드에 PER/EPS/시총 데이터가 아직 없으므로 그 항목들은 의도적으로 제외.
|
// 차트 폴링과 독립적으로 1Y 일봉 + 펀더멘털 스냅샷을 한 번씩 받는다.
|
||||||
// 차트 폴링과 독립적으로 1Y 일봉을 한 번만 받아 52주 범위만 계산한다.
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 }) {
|
export function SymbolSidebar({ code }: { code: string }) {
|
||||||
const [chart, setChart] = useState<ChartPayload | null>(null);
|
const [chart, setChart] = useState<ChartPayload | null>(null);
|
||||||
|
const [fund, setFund] = useState<FundamentalsResponse | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setChart(null);
|
setChart(null);
|
||||||
|
setFund(null);
|
||||||
api
|
api
|
||||||
.getChart(code, 365, "1d")
|
.getChart(code, 365, "1d")
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
@@ -28,6 +30,15 @@ export function SymbolSidebar({ code }: { code: string }) {
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
if (alive) setErr(e instanceof Error ? e.message : String(e));
|
||||||
});
|
});
|
||||||
|
// 펀더멘털은 차트와 독립 — 실패해도 시세 표시는 유지.
|
||||||
|
api
|
||||||
|
.fundamentals(code)
|
||||||
|
.then((f) => {
|
||||||
|
if (alive) setFund(f);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 무시: 펀더멘털 없으면 그 섹션만 숨김.
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
@@ -97,6 +108,32 @@ export function SymbolSidebar({ code }: { code: string }) {
|
|||||||
pos={stats.yearPos}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,3 +203,32 @@ function fmtCompact(n: number | null | undefined): string {
|
|||||||
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`;
|
if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`;
|
||||||
return n.toLocaleString();
|
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)}%`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -254,6 +254,22 @@ export type OrderbookLevel = {
|
|||||||
qty: number;
|
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 = {
|
export type OrderbookResponse = {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -334,4 +350,6 @@ export const api = {
|
|||||||
),
|
),
|
||||||
orderbook: (code: string) =>
|
orderbook: (code: string) =>
|
||||||
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
|
getJson<OrderbookResponse>(`/api/orderbook/${encodeURIComponent(code)}`),
|
||||||
|
fundamentals: (code: string) =>
|
||||||
|
getJson<FundamentalsResponse>(`/api/fundamentals/${encodeURIComponent(code)}`),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user