시장 주요 지표 참고 추가
This commit is contained in:
29
src/api.js
29
src/api.js
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<div>
|
||||
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? 0).color }}>
|
||||
{getVixLevel(vixData.value ?? 0).label}
|
||||
</p>
|
||||
{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>
|
||||
<p className="stock-vix__label" style={{ color: getVixLevel(vixData.value ?? vixData.vix ?? 0).color }}>
|
||||
{getVixLevel(vixData.value ?? vixData.vix ?? 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>
|
||||
<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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user