diff --git a/backend/app/api/fundamentals.py b/backend/app/api/fundamentals.py new file mode 100644 index 0000000..68ae3b1 --- /dev/null +++ b/backend/app/api/fundamentals.py @@ -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, + } diff --git a/backend/app/fetch/fundamentals.py b/backend/app/fetch/fundamentals.py new file mode 100644 index 0000000..a2db16d --- /dev/null +++ b/backend/app/fetch/fundamentals.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index daf4a33..6ff06a4 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.fundamentals import router as fundamentals_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 @@ -108,6 +109,7 @@ app.include_router(news_router) app.include_router(markets_router) app.include_router(themes_router) app.include_router(orderbook_router) +app.include_router(fundamentals_router) def _resolved_device() -> str: diff --git a/web/components/SymbolSidebar.tsx b/web/components/SymbolSidebar.tsx index 2fc54d9..7b78b74 100644 --- a/web/components/SymbolSidebar.tsx +++ b/web/components/SymbolSidebar.tsx @@ -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(null); + const [fund, setFund] = useState(null); const [err, setErr] = useState(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" && } + + ); +} + +// 시가총액 / PER / PBR / EPS / BPS / 배당수익률. KRX 스냅샷 일자 표시. +// 값 미존재 (e.g. 신규 상장으로 EPS 안 나옴) 항목은 자동으로 "-". +function FundamentalsBlock({ f }: { f: FundamentalsResponse }) { + return ( +
+
+ 기업 지표 + {f.snapshot_date && ( + {f.snapshot_date} 기준 + )} +
+
+ + + + + + + + +
); } @@ -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)}%`; +} diff --git a/web/lib/api.ts b/web/lib/api.ts index a5ee534..1759d04 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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(`/api/orderbook/${encodeURIComponent(code)}`), + fundamentals: (code: string) => + getJson(`/api/fundamentals/${encodeURIComponent(code)}`), };