시장 주요 지표 참고 추가

This commit is contained in:
2026-03-05 02:45:45 +09:00
parent ccc9f7c634
commit c28bd9368c
4 changed files with 372 additions and 32 deletions

View File

@@ -156,16 +156,33 @@ export async function getFearAndGreed() {
return res.json();
}
// VIX 지수 (Yahoo Finance 공개 API)
export async function getVix() {
const res = await fetch('/ext/vix', { headers: { Accept: 'application/json' } });
// Yahoo Finance chart API 공통 파서
async function fetchYahooPrice(extPath) {
const res = await fetch(extPath, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const price = data?.chart?.result?.[0]?.meta?.regularMarketPrice;
if (price === undefined || price === null) throw new Error('VIX 데이터 없음');
return { value: Math.round(price * 100) / 100 };
const meta = data?.chart?.result?.[0]?.meta;
const price = meta?.regularMarketPrice;
const prevClose = meta?.previousClose ?? meta?.chartPreviousClose;
if (price == null) throw new Error('데이터 없음');
const rounded = Math.round(price * 100) / 100;
const change = prevClose != null ? Math.round((price - prevClose) * 100) / 100 : null;
const changePercent = prevClose ? Math.round(((price - prevClose) / prevClose) * 10000) / 100 : null;
return { value: rounded, change, changePercent };
}
// VIX 지수 (Yahoo Finance 공개 API)
export function getVix() { return fetchYahooPrice('/ext/vix'); }
// 미국 10년물 국채 금리 (^TNX)
export function getTreasury10Y() { return fetchYahooPrice('/ext/treasury'); }
// WTI 원유 선물 (CL=F)
export function getWTI() { return fetchYahooPrice('/ext/wti'); }
// Brent 원유 선물 (BZ=F)
export function getBrent() { return fetchYahooPrice('/ext/brent'); }
// ── TODO API ─────────────────────────────────────────────────────────────────
export function getTodos() {

View File

@@ -1570,6 +1570,14 @@
gap: 6px;
padding: 12px 0 8px;
text-align: center;
width: 100%;
}
.stock-vix__top {
display: flex;
align-items: center;
gap: 16px;
justify-content: center;
}
.stock-vix__score {
@@ -1586,17 +1594,70 @@
transition: color 0.4s ease;
}
.stock-vix__legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
justify-content: center;
margin-top: 10px;
font-size: 11px;
.stock-vix__change {
margin: 4px 0 0;
font-size: 12px;
color: var(--muted);
}
.stock-vix__legend span {
font-weight: 500;
.stock-vix__change.is-up { color: #f04452; }
.stock-vix__change.is-down { color: #3b82f6; }
/* VIX 구간별 설명 목록 */
.stock-vix__levels {
display: grid;
gap: 6px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
width: 100%;
text-align: left;
}
.stock-vix__level {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
transition: background 0.2s ease, border-color 0.2s ease;
}
.stock-vix__level.is-current {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.stock-vix__level-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.stock-vix__level-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.stock-vix__level-label {
font-size: 12px;
font-weight: 700;
}
.stock-vix__level-range {
font-size: 11px;
color: var(--muted);
margin-left: auto;
}
.stock-vix__level-desc {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
padding-left: 16px;
}
/* ══════════════════════════════════════════════════════════════════
@@ -1680,3 +1741,117 @@
grid-template-columns: 1fr;
}
}
/* ══════════════════════════════════════════════════════════════════
매크로 지표 카드
══════════════════════════════════════════════════════════════════ */
.stock-macro-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.stock-macro-card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
background: rgba(0, 0, 0, 0.2);
display: grid;
gap: 6px;
}
.stock-macro-card__title {
margin: 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.stock-macro-card__value {
font-size: 32px;
font-weight: 800;
color: var(--text);
line-height: 1;
}
.stock-macro-card__change {
margin: 0;
font-size: 12px;
color: var(--muted);
}
.stock-macro-card__change.is-up { color: #f04452; }
.stock-macro-card__change.is-down { color: #3b82f6; }
.stock-macro-card__desc {
margin: 6px 0 0;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
border-top: 1px solid var(--line);
padding-top: 8px;
}
/* ══════════════════════════════════════════════════════════════════
시장 건강 지표 (Placeholder)
══════════════════════════════════════════════════════════════════ */
.stock-health-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.stock-placeholder-card {
border: 1px dashed var(--line);
border-radius: 14px;
padding: 16px;
background: rgba(0, 0, 0, 0.1);
display: grid;
gap: 8px;
opacity: 0.75;
}
.stock-placeholder-card__title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.stock-placeholder-card__status {
font-size: 13px;
color: var(--muted);
}
.stock-placeholder-card__desc {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
.stock-placeholder-card__api {
font-size: 11px;
color: var(--muted);
opacity: 0.7;
font-family: monospace;
background: rgba(0, 0, 0, 0.2);
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--line);
display: inline-block;
}
@media (max-width: 768px) {
.stock-macro-grid,
.stock-health-grid {
grid-template-columns: 1fr;
}
.stock-macro-card__value {
font-size: 26px;
}
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews, getFearAndGreed, getVix } from '../../api';
import { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api';
import Loading from '../../components/Loading';
import FearGreedGauge from '../../components/FearGreedGauge';
import './Stock.css';
@@ -77,12 +77,35 @@ const getDirection = (change, percent, direction) => {
return '';
};
const VIX_LEVELS = [
{
range: '0 12', label: '극히 낮음', color: '#22c55e',
desc: '시장이 극도로 안정적. 오히려 투자자 안일함의 신호일 수 있어, 갑작스러운 조정에 대비가 필요합니다.',
},
{
range: '12 20', label: '정상', color: '#84cc16',
desc: '시장이 안정적인 상태. 보통 상승장에서 나타나며, 건강한 변동성 수준입니다.',
},
{
range: '20 30', label: '주의', color: '#eab308',
desc: '불확실성이 높아지는 구간. 주가와 반대로 움직이며, 단기 바닥 신호로 해석되기도 합니다.',
},
{
range: '30 40', label: '높음', color: '#f97316',
desc: '극도의 공포가 퍼진 상태. 급격한 매도세가 나타나지만, 역사적으로 역발상 매수 기회가 되기도 합니다.',
},
{
range: '40+', label: '극단', color: '#ef4444',
desc: '패닉 수준의 공포. 2008 금융위기·2020 코로나 때 발생. VIX가 꺾이기 시작하면 심리적 진정의 시작입니다.',
},
];
const getVixLevel = (score) => {
if (score < 12) return { label: '극히 낮음', color: '#22c55e' };
if (score < 20) return { label: '정상', color: '#84cc16' };
if (score < 30) return { label: '보통', color: '#eab308' };
if (score < 40) return { label: '높음', color: '#f97316' };
return { label: '극단', color: '#ef4444' };
if (score < 12) return VIX_LEVELS[0];
if (score < 20) return VIX_LEVELS[1];
if (score < 30) return VIX_LEVELS[2];
if (score < 40) return VIX_LEVELS[3];
return VIX_LEVELS[4];
};
const Stock = () => {
@@ -99,6 +122,7 @@ const Stock = () => {
const [fgData, setFgData] = useState(null);
const [vixData, setVixData] = useState(null);
const [macroData, setMacroData] = useState({ treasury: null, wti: null, brent: null });
const combinedNews = useMemo(
() => [...newsDomestic, ...newsOverseas],
@@ -156,6 +180,14 @@ const Stock = () => {
})
.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 indexOrder = [
@@ -307,18 +339,36 @@ const Stock = () => {
</div>
{vixData ? (
<div className="stock-vix">
<div className="stock-vix__score" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
{vixData.value ?? vixData.vix ?? '--'}
<div className="stock-vix__top">
<div className="stock-vix__score" style={{ color: getVixLevel(vixData.value ?? 0).color }}>
{vixData.value ?? '--'}
</div>
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
{getVixLevel(vixData.value ?? vixData.vix ?? 0).label}
<div>
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? 0).color }}>
{getVixLevel(vixData.value ?? 0).label}
</p>
<div className="stock-vix__legend">
<span style={{ color: '#22c55e' }}>{'<12'} 극히낮음</span>
<span style={{ color: '#84cc16' }}>12-20 정상</span>
<span style={{ color: '#eab308' }}>20-30 보통</span>
<span style={{ color: '#f97316' }}>30-40 높음</span>
<span style={{ color: '#ef4444' }}>{'40+'} 극단</span>
{vixData.change != null && (
<p className={`stock-vix__change ${vixData.change >= 0 ? 'is-up' : 'is-down'}`}>
{vixData.change >= 0 ? '+' : ''}{vixData.change}
{vixData.changePercent != null && ` (${vixData.changePercent >= 0 ? '+' : ''}${vixData.changePercent}%)`}
</p>
)}
</div>
</div>
<div className="stock-vix__levels">
{VIX_LEVELS.map((level) => (
<div
key={level.range}
className={`stock-vix__level ${level.label === getVixLevel(vixData.value ?? 0).label ? 'is-current' : ''}`}
>
<div className="stock-vix__level-head">
<span className="stock-vix__level-dot" style={{ background: level.color }} />
<span className="stock-vix__level-label" style={{ color: level.color }}>{level.label}</span>
<span className="stock-vix__level-range">{level.range}</span>
</div>
<p className="stock-vix__level-desc">{level.desc}</p>
</div>
))}
</div>
</div>
) : (
@@ -327,6 +377,83 @@ const Stock = () => {
</div>
</section>
{/* 매크로 지표 섹션 */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">글로벌 매크로</p>
<h3>매크로 지표</h3>
<p className="stock-panel__sub">금리·원자재 주요 거시경제 지표를 확인합니다.</p>
</div>
</div>
<div className="stock-macro-grid">
<div className="stock-macro-card">
<p className="stock-macro-card__title">미국 10년물 국채 금리</p>
<div className="stock-macro-card__value">
{macroData.treasury ? `${macroData.treasury.value}%` : '--'}
</div>
{macroData.treasury?.change != null && (
<p className={`stock-macro-card__change ${macroData.treasury.change >= 0 ? 'is-up' : 'is-down'}`}>
{macroData.treasury.change >= 0 ? '+' : ''}{macroData.treasury.change}
{macroData.treasury.changePercent != null && ` (${macroData.treasury.changePercent >= 0 ? '+' : ''}${macroData.treasury.changePercent}%)`}
</p>
)}
<p className="stock-macro-card__desc">금리 상승 주식 밸류에이션 압박. 4% 이상 지속은 주식 하락 압력 신호. 단기 급등은 인플레이션 우려를 반영합니다.</p>
</div>
<div className="stock-macro-card">
<p className="stock-macro-card__title">WTI 유가</p>
<div className="stock-macro-card__value">
{macroData.wti ? `$${macroData.wti.value}` : '--'}
</div>
{macroData.wti?.change != null && (
<p className={`stock-macro-card__change ${macroData.wti.change >= 0 ? 'is-up' : 'is-down'}`}>
{macroData.wti.change >= 0 ? '+' : ''}{macroData.wti.change}
{macroData.wti.changePercent != null && ` (${macroData.wti.changePercent >= 0 ? '+' : ''}${macroData.wti.changePercent}%)`}
</p>
)}
<p className="stock-macro-card__desc">에너지 인플레이션 지표. $80 이상 지속 물가 상승 우려 확대. 급락은 경기침체 가능성을 반영하기도 합니다.</p>
</div>
<div className="stock-macro-card">
<p className="stock-macro-card__title">Brent 유가</p>
<div className="stock-macro-card__value">
{macroData.brent ? `$${macroData.brent.value}` : '--'}
</div>
{macroData.brent?.change != null && (
<p className={`stock-macro-card__change ${macroData.brent.change >= 0 ? 'is-up' : 'is-down'}`}>
{macroData.brent.change >= 0 ? '+' : ''}{macroData.brent.change}
{macroData.brent.changePercent != null && ` (${macroData.brent.changePercent >= 0 ? '+' : ''}${macroData.brent.changePercent}%)`}
</p>
)}
<p className="stock-macro-card__desc">국제 기준 유가. WTI와 함께 에너지 시장 방향을 파악하는 활용. 지정학 리스크 WTI 대비 프리미엄 형성.</p>
</div>
</div>
</section>
{/* 시장 건강 지표 (Placeholder) */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">시장 건강</p>
<h3>시장 건강 지표</h3>
<p className="stock-panel__sub">백엔드 API 연동 실시간 데이터를 표시합니다.</p>
</div>
</div>
<div className="stock-health-grid">
<div className="stock-placeholder-card">
<p className="stock-placeholder-card__title">ADR (등락주선 비율)</p>
<div className="stock-placeholder-card__status">🔧 데이터 준비 </div>
<p className="stock-placeholder-card__desc">일정 기간 상승종목 ÷ (상승+하락) 종목 비율. 0.5 이상 = 폭넓은 상승장. 0.3 이하 = 일부 대형주만 오르는 약세 신호.</p>
<code className="stock-placeholder-card__api">GET /api/stock/adr</code>
</div>
<div className="stock-placeholder-card">
<p className="stock-placeholder-card__title">고객예탁금 / 신용융자</p>
<div className="stock-placeholder-card__status">🔧 데이터 준비 </div>
<p className="stock-placeholder-card__desc">고객예탁금 증가 = 투자 대기자금 유입 = 강세. 신용융자 급증 = 과열 경고. 예탁금 감소 + 신용 급증 = 위험 구간.</p>
<code className="stock-placeholder-card__api">GET /api/stock/deposit</code>
</div>
</div>
</section>
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>

View File

@@ -38,6 +38,27 @@ export default defineConfig({
secure: true,
rewrite: () => '/v8/finance/chart/%5EVIX?interval=1d&range=1d',
},
// 미국 10년물 국채 금리 (^TNX)
'/ext/treasury': {
target: 'https://query1.finance.yahoo.com',
changeOrigin: true,
secure: true,
rewrite: () => '/v8/finance/chart/%5ETNX?interval=1d&range=1d',
},
// WTI 원유 선물 (CL=F)
'/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)
'/ext/brent': {
target: 'https://query1.finance.yahoo.com',
changeOrigin: true,
secure: true,
rewrite: () => '/v8/finance/chart/BZ%3DF?interval=1d&range=1d',
},
},
},
})