From fa696b0c90581030284d177aad6c32a474fcae9a Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 5 Mar 2026 03:12:25 +0900 Subject: [PATCH] =?UTF-8?q?stock=20=EC=A7=80=ED=91=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20=EC=9E=90=EC=82=B0=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=ED=83=AD=20=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/stock/Stock.css | 87 +++++++++++++++++++++++ src/pages/stock/Stock.jsx | 39 +++++----- src/pages/stock/StockTrade.jsx | 125 ++++++++++++++++++++++++++++++++- vite.config.js | 21 +++++- 4 files changed, 251 insertions(+), 21 deletions(-) diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index 789d405..b5fce04 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -1854,4 +1854,91 @@ .stock-macro-card__value { font-size: 26px; } +} + +/* ══════════════════════════════════════════════════════════════════ + 리스크 분산 분석 + ══════════════════════════════════════════════════════════════════ */ + +.risk-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; +} + +.risk-card { + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px; + background: rgba(0, 0, 0, 0.2); + display: grid; + gap: 10px; +} + +.risk-card__title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.risk-item { + display: grid; + gap: 4px; +} + +.risk-item__head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} + +.risk-item__name { + color: var(--text); + font-weight: 500; +} + +.risk-item__ratio { + font-weight: 700; + font-size: 13px; +} + +.risk-item__ratio.is-ok { color: #34d399; } +.risk-item__ratio.is-warn { color: #f97316; } +.risk-item__ratio.is-danger { color: #ef4444; } + +.risk-bar { + height: 4px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; +} + +.risk-bar__fill { + height: 100%; + border-radius: 2px; + transition: width 0.4s ease; +} + +.risk-bar__fill.is-ok { background: #34d399; } +.risk-bar__fill.is-warn { background: #f97316; } +.risk-bar__fill.is-danger { background: #ef4444; } + +.risk-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + background: rgba(249, 115, 22, 0.1); + border: 1px solid rgba(249, 115, 22, 0.3); + font-size: 12px; + color: #fdba74; +} + +@media (max-width: 640px) { + .risk-grid { + grid-template-columns: 1fr; + } } \ No newline at end of file diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx index df77847..53e4f20 100644 --- a/src/pages/stock/Stock.jsx +++ b/src/pages/stock/Stock.jsx @@ -170,24 +170,29 @@ const Stock = () => { }, [autoRefreshMs]); useEffect(() => { - getFearAndGreed() - .then((data) => { - const fg = data?.fear_and_greed ?? data; - const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score); - if (!isNaN(score)) { - setFgData({ score, timestamp: fg?.timestamp ?? null }); - } - }) - .catch(() => { }); - getVix().then(setVixData).catch(() => { }); - Promise.allSettled([getTreasury10Y(), getWTI(), getBrent()]) - .then(([t, w, b]) => { - setMacroData({ - treasury: t.status === 'fulfilled' ? t.value : null, - wti: w.status === 'fulfilled' ? w.value : null, - brent: b.status === 'fulfilled' ? b.value : null, + const loadSentiment = () => { + getFearAndGreed() + .then((data) => { + const fg = data?.fear_and_greed ?? data; + const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score); + if (!isNaN(score)) { + setFgData({ score, timestamp: fg?.timestamp ?? null }); + } + }) + .catch(() => { }); + getVix().then(setVixData).catch(() => { }); + Promise.allSettled([getTreasury10Y(), getWTI(), getBrent()]) + .then(([t, w, b]) => { + setMacroData({ + treasury: t.status === 'fulfilled' ? t.value : null, + wti: w.status === 'fulfilled' ? w.value : null, + brent: b.status === 'fulfilled' ? b.value : null, + }); }); - }); + }; + loadSentiment(); + const timer = window.setInterval(loadSentiment, 600000); // 10분마다 갱신 + return () => window.clearInterval(timer); }, []); const indexOrder = [ diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx index c8d5016..83d5270 100644 --- a/src/pages/stock/StockTrade.jsx +++ b/src/pages/stock/StockTrade.jsx @@ -564,6 +564,43 @@ ${holdingsText} [portfolioHoldings] ); + /* ── derived: 리스크 분산 분석 ────────────────────────────────── */ + + const brokerConcentration = useMemo(() => { + const totalEval = toNumeric(portfolioSummary.total_eval); + if (!totalEval || totalEval === 0) return []; + return brokerGroups + .map(([broker, items]) => { + const { totalEval: brokerEval } = getBrokerSummary(items); + const ratio = Math.round((brokerEval / totalEval) * 1000) / 10; + return { broker, eval: brokerEval, ratio }; + }) + .sort((a, b) => b.ratio - a.ratio); + }, [brokerGroups, portfolioSummary.total_eval]); // eslint-disable-line react-hooks/exhaustive-deps + + const stockConcentration = useMemo(() => { + const totalEval = toNumeric(portfolioSummary.total_eval); + if (!totalEval || totalEval === 0) return []; + return portfolioHoldings + .map((item) => { + const evalAmt = item.eval_amount != null + ? toNumeric(item.eval_amount) + : (item.current_price != null && item.quantity != null) + ? toNumeric(item.current_price) * toNumeric(item.quantity) + : null; + if (!evalAmt) return null; + return { + name: item.name ?? item.ticker ?? 'N/A', + ticker: item.ticker ?? '', + eval: evalAmt, + ratio: Math.round((evalAmt / totalEval) * 1000) / 10, + }; + }) + .filter(Boolean) + .sort((a, b) => b.ratio - a.ratio) + .slice(0, 5); + }, [portfolioHoldings, portfolioSummary.total_eval]); + const sortedHoldings = useMemo(() => { const getVal = (item) => { switch (reportSortField) { @@ -1476,6 +1513,84 @@ ${holdingsText} )} + {/* ── 리스크 분산 분석 ─────────────────────────────── */} + {portfolioHoldings.length > 0 && portfolioSummary.total_eval != null && ( +
+
+
+

리스크 관리

+

분산 분석

+

증권사·종목 집중도를 확인합니다. 단일 비중 40% 초과 시 주의.

+
+
+
+ {/* 증권사별 집중도 */} +
+

증권사별 집중도

+ {brokerConcentration.length === 0 ? ( +

평가금액 데이터 없음

+ ) : ( + <> + {brokerConcentration.some((b) => b.ratio > 40) && ( +
+ ⚠️ 단일 증권사 집중도가 40%를 초과합니다 +
+ )} + {brokerConcentration.map(({ broker, eval: evalAmt, ratio }) => { + const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok'; + return ( +
+
+ {broker} + {ratio.toFixed(1)}% +
+
+
+
+ {formatNumber(evalAmt)}원 +
+ ); + })} + + )} +
+ {/* 종목별 집중도 */} +
+

상위 5 종목 집중도

+ {stockConcentration.length === 0 ? ( +

현재가 데이터 없음

+ ) : ( + <> + {stockConcentration.some((s) => s.ratio > 40) && ( +
+ ⚠️ 단일 종목 집중도가 40%를 초과합니다 +
+ )} + {stockConcentration.map(({ name, ticker, eval: evalAmt, ratio }) => { + const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok'; + return ( +
+
+ {name} + {ratio.toFixed(1)}% +
+
+
+
+ + {ticker && {ticker}} + {formatNumber(evalAmt)}원 + +
+ ); + })} + + )} +
+
+
+ )} + {/* ── 수익률 랭킹 테이블 ─────────────────────────── */} {portfolioHoldings.length > 0 && (
@@ -1483,7 +1598,7 @@ ${holdingsText}

수익률 랭킹

종목별 상세 현황

-

헤더 클릭으로 정렬

+

헤더 클릭으로 정렬 · 비중은 총 평가금액 대비

@@ -1506,6 +1621,7 @@ ${holdingsText} ))} + 비중 @@ -1517,6 +1633,10 @@ ${holdingsText} : item.current_price != null ? item.current_price * item.quantity : null; + const totalEval = toNumeric(portfolioSummary.total_eval); + const weight = evalAmt != null && totalEval + ? Math.round((evalAmt / totalEval) * 1000) / 10 + : null; return ( @@ -1543,6 +1663,9 @@ ${holdingsText} {evalAmt != null ? formatNumber(evalAmt) : '-'} + + {weight != null ? `${weight.toFixed(1)}%` : '-'} + ); })} diff --git a/vite.config.js b/vite.config.js index 8e4cb45..eaf1c98 100644 --- a/vite.config.js +++ b/vite.config.js @@ -38,21 +38,36 @@ export default defineConfig({ secure: true, rewrite: () => '/v8/finance/chart/%5EVIX?interval=1d&range=1d', }, - // 미국 10년물 국채 금리 (^TNX) + // 미국 10년물 국채 금리 (^TNX) — Yahoo Finance + // 프로덕션 nginx 설정 필요: + // location /ext/treasury { + // proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/%5ETNX?interval=1d&range=1d; + // proxy_set_header Host query1.finance.yahoo.com; + // } '/ext/treasury': { target: 'https://query1.finance.yahoo.com', changeOrigin: true, secure: true, rewrite: () => '/v8/finance/chart/%5ETNX?interval=1d&range=1d', }, - // WTI 원유 선물 (CL=F) + // WTI 원유 선물 (CL=F) — Yahoo Finance + // 프로덕션 nginx 설정 필요: + // location /ext/wti { + // proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/CL%3DF?interval=1d&range=1d; + // proxy_set_header Host query1.finance.yahoo.com; + // } '/ext/wti': { target: 'https://query1.finance.yahoo.com', changeOrigin: true, secure: true, rewrite: () => '/v8/finance/chart/CL%3DF?interval=1d&range=1d', }, - // Brent 원유 선물 (BZ=F) + // Brent 원유 선물 (BZ=F) — Yahoo Finance + // 프로덕션 nginx 설정 필요: + // location /ext/brent { + // proxy_pass https://query1.finance.yahoo.com/v8/finance/chart/BZ%3DF?interval=1d&range=1d; + // proxy_set_header Host query1.finance.yahoo.com; + // } '/ext/brent': { target: 'https://query1.finance.yahoo.com', changeOrigin: true,