566 lines
27 KiB
JavaScript
566 lines
27 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api';
|
||
import Loading from '../../components/Loading';
|
||
import FearGreedGauge from '../../components/FearGreedGauge';
|
||
import './Stock.css';
|
||
|
||
const formatDate = (value) => {
|
||
if (!value) return '-';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleString('sv-SE');
|
||
};
|
||
|
||
const normalizeIndices = (data) => {
|
||
if (!data) return [];
|
||
|
||
if (Array.isArray(data)) {
|
||
return data.map((item) => ({
|
||
name: item?.name ?? item?.key ?? '-',
|
||
value: item?.value ?? '-',
|
||
change: item?.change_value ?? item?.change ?? '',
|
||
percent: item?.change_percent ?? item?.percent ?? '',
|
||
direction: item?.direction ?? '',
|
||
}));
|
||
}
|
||
|
||
if (Array.isArray(data?.indices)) {
|
||
return data.indices.map((item) => ({
|
||
name: item?.name ?? item?.key ?? '-',
|
||
value: item?.value ?? '-',
|
||
change: item?.change_value ?? item?.change ?? '',
|
||
percent: item?.change_percent ?? item?.percent ?? '',
|
||
direction: item?.direction ?? '',
|
||
}));
|
||
}
|
||
|
||
if (typeof data === 'object') {
|
||
return Object.entries(data)
|
||
.filter(([, value]) => value && typeof value === 'object')
|
||
.map(([name, value]) => ({
|
||
name,
|
||
value: value?.value ?? '-',
|
||
change: value?.change ?? '',
|
||
percent: value?.percent ?? '',
|
||
direction: value?.direction ?? '',
|
||
}));
|
||
}
|
||
|
||
return [];
|
||
};
|
||
|
||
const getDirection = (change, percent, direction) => {
|
||
// 숫자 부호로 방향 추출 (percent → change 순서로 시도)
|
||
const fromStr = (s) => {
|
||
if (s === undefined || s === null || s === '') return null;
|
||
const str = String(s).trim();
|
||
if (str.startsWith('-')) return 'down';
|
||
if (str.startsWith('+')) return 'up';
|
||
const numeric = Number(str.replace(/[^0-9.-]/g, ''));
|
||
if (Number.isFinite(numeric) && numeric !== 0) {
|
||
return numeric > 0 ? 'up' : 'down';
|
||
}
|
||
return null;
|
||
};
|
||
// percent 필드가 부호를 가장 신뢰성 있게 포함하는 경우가 많음
|
||
const byPercent = fromStr(percent);
|
||
if (byPercent) return byPercent;
|
||
const byChange = fromStr(change);
|
||
if (byChange) return byChange;
|
||
// 숫자로 판별 불가 시 direction 필드 fallback
|
||
if (direction) {
|
||
const d = String(direction).toLowerCase();
|
||
if (d === 'red' || d === 'up' || d === 'rise' || d === 'positive') return 'up';
|
||
if (d === 'blue' || d === 'down' || d === 'fall' || d === 'negative') return 'down';
|
||
}
|
||
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 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 = () => {
|
||
const [newsDomestic, setNewsDomestic] = useState([]);
|
||
const [newsOverseas, setNewsOverseas] = useState([]);
|
||
const [newsCategory, setNewsCategory] = useState('domestic');
|
||
const [limit, setLimit] = useState(20);
|
||
const [loading, setLoading] = useState(false);
|
||
const [newsError, setNewsError] = useState('');
|
||
const [indicesError, setIndicesError] = useState('');
|
||
const [indices, setIndices] = useState([]);
|
||
const [indicesLoading, setIndicesLoading] = useState(false);
|
||
const [autoRefreshMs] = useState(180000);
|
||
|
||
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],
|
||
[newsDomestic, newsOverseas]
|
||
);
|
||
|
||
const loadNews = async () => {
|
||
setLoading(true);
|
||
setNewsError('');
|
||
try {
|
||
const [domestic, overseas] = await Promise.all([
|
||
getStockNews(limit, 'domestic'),
|
||
getStockNews(limit, 'overseas'),
|
||
]);
|
||
setNewsDomestic(Array.isArray(domestic) ? domestic : []);
|
||
setNewsOverseas(Array.isArray(overseas) ? overseas : []);
|
||
} catch (err) {
|
||
setNewsError(err?.message ?? String(err));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadIndices = async () => {
|
||
setIndicesLoading(true);
|
||
setIndicesError('');
|
||
try {
|
||
const data = await getStockIndices();
|
||
setIndices(normalizeIndices(data));
|
||
} catch (err) {
|
||
setIndicesError(err?.message ?? String(err));
|
||
} finally {
|
||
setIndicesLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadNews();
|
||
}, [limit]);
|
||
|
||
useEffect(() => {
|
||
loadIndices();
|
||
const timer = window.setInterval(loadIndices, autoRefreshMs);
|
||
return () => window.clearInterval(timer);
|
||
}, [autoRefreshMs]);
|
||
|
||
useEffect(() => {
|
||
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 = [
|
||
'KOSPI',
|
||
'KOSDAQ',
|
||
'KOSPI200',
|
||
'다우산업',
|
||
'나스닥',
|
||
'S&P500',
|
||
'원달러 환율',
|
||
];
|
||
const sortedIndices = [...indices].sort((a, b) => {
|
||
const aIndex = indexOrder.indexOf(a.name);
|
||
const bIndex = indexOrder.indexOf(b.name);
|
||
if (aIndex !== -1 || bIndex !== -1) {
|
||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
|
||
}
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
const highlighted = new Set(['KOSPI', 'KOSDAQ', '원달러 환율']);
|
||
const activeNews =
|
||
newsCategory === 'domestic' ? newsDomestic : newsOverseas;
|
||
|
||
return (
|
||
<div className="stock">
|
||
<header className="stock-header">
|
||
<div>
|
||
<p className="stock-kicker">마켓 랩</p>
|
||
<h1>주식 랩</h1>
|
||
<p className="stock-sub">
|
||
최신 시장 뉴스와 주요 지수 흐름을 한눈에 확인하세요.
|
||
</p>
|
||
<div className="stock-actions">
|
||
<button
|
||
className="button primary"
|
||
onClick={loadNews}
|
||
disabled={loading}
|
||
>
|
||
뉴스 새로고침
|
||
</button>
|
||
<Link className="button ghost" to="/stock/trade">
|
||
거래 데스크
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
<div className="stock-card">
|
||
<p className="stock-card__title">다음 업데이트 아이디어</p>
|
||
<ul className="stock-ideas">
|
||
<li>관심 종목 실적 캘린더/일정 보기</li>
|
||
<li>뉴스 감성 요약 및 키워드 트렌드</li>
|
||
<li>보유 종목 알림(수익률/목표가)</li>
|
||
</ul>
|
||
</div>
|
||
</header>
|
||
|
||
<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 className="stock-panel__actions">
|
||
{indicesLoading ? (
|
||
<Loading type="spinner" message="" />
|
||
) : null}
|
||
<button
|
||
className="button ghost small"
|
||
onClick={loadIndices}
|
||
disabled={indicesLoading}
|
||
>
|
||
새로고침
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="stock-snapshot">
|
||
{indicesError ? (
|
||
<p className="stock-empty">{indicesError}</p>
|
||
) : sortedIndices.length === 0 ? (
|
||
<p className="stock-empty">
|
||
지수 데이터가 없습니다.
|
||
</p>
|
||
) : (
|
||
sortedIndices.map((item) => {
|
||
const direction = getDirection(
|
||
item.change,
|
||
item.percent,
|
||
item.direction
|
||
);
|
||
const changeText = [item.change, item.percent]
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
return (
|
||
<div
|
||
key={item.name}
|
||
className={`stock-snapshot__card ${highlighted.has(item.name)
|
||
? 'is-highlight'
|
||
: ''
|
||
}`}
|
||
>
|
||
<p>{item.name}</p>
|
||
<strong>{item.value ?? '--'}</strong>
|
||
<span
|
||
className={`stock-snapshot__change ${direction === 'up'
|
||
? 'is-up'
|
||
: direction === 'down'
|
||
? 'is-down'
|
||
: ''
|
||
}`}
|
||
>
|
||
{changeText || '--'}
|
||
</span>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* 시장 심리 지표 행 */}
|
||
<section className="stock-filter-row">
|
||
<div className="stock-panel stock-panel--compact">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">심리 지표</p>
|
||
<h3>Fear & Greed</h3>
|
||
<p className="stock-panel__sub">시장 탐욕·공포 지수 (0–100)</p>
|
||
</div>
|
||
</div>
|
||
{fgData ? (
|
||
<FearGreedGauge
|
||
score={Math.round(fgData.score)}
|
||
date={fgData.timestamp ? new Date(fgData.timestamp).toLocaleDateString('ko-KR') : undefined}
|
||
showLevels
|
||
/>
|
||
) : (
|
||
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||
)}
|
||
</div>
|
||
<div className="stock-panel stock-panel--compact">
|
||
<div className="stock-panel__head">
|
||
<div>
|
||
<p className="stock-panel__eyebrow">변동성 지수</p>
|
||
<h3>VIX</h3>
|
||
<p className="stock-panel__sub">CBOE 공포 지수</p>
|
||
</div>
|
||
</div>
|
||
{vixData ? (
|
||
<div className="stock-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>
|
||
<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>
|
||
) : (
|
||
<p className="stock-empty" style={{ fontSize: 13 }}>데이터 없음</p>
|
||
)}
|
||
</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>
|
||
<p className="stock-panel__eyebrow">헤드라인</p>
|
||
<h3>시장 뉴스</h3>
|
||
<p className="stock-panel__sub">
|
||
Stock Lab API에서 최신 뉴스를 불러옵니다.
|
||
</p>
|
||
</div>
|
||
<div className="stock-panel__actions">
|
||
{loading && <span className="stock-chip">Updating...</span>}
|
||
<span className="stock-chip">
|
||
국내 {newsDomestic.length} / 해외{' '}
|
||
{newsOverseas.length}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{loading && combinedNews.length === 0 ? (
|
||
<Loading type="skeleton" />
|
||
) : newsError ? (
|
||
<p className="stock-empty">{newsError}</p>
|
||
) : combinedNews.length === 0 ? (
|
||
<p className="stock-empty">뉴스가 없습니다.</p>
|
||
) : (
|
||
<>
|
||
<div className="stock-news-toolbar">
|
||
<div className="stock-tabs">
|
||
<button
|
||
type="button"
|
||
className={`stock-tab ${newsCategory === 'domestic' ? 'is-active' : ''}`}
|
||
onClick={() => setNewsCategory('domestic')}
|
||
>
|
||
국내 <span className="stock-tab-count">{newsDomestic.length}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`stock-tab ${newsCategory === 'overseas' ? 'is-active' : ''}`}
|
||
onClick={() => setNewsCategory('overseas')}
|
||
>
|
||
해외 <span className="stock-tab-count">{newsOverseas.length}</span>
|
||
</button>
|
||
</div>
|
||
<select
|
||
className="stock-news-limit"
|
||
value={limit}
|
||
onChange={(e) => setLimit(Number(e.target.value))}
|
||
>
|
||
{[10, 20, 30, 40].map((v) => (
|
||
<option key={v} value={v}>{v}개</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{activeNews.length === 0 ? (
|
||
<p className="stock-empty">
|
||
해당 카테고리 뉴스가 없습니다.
|
||
</p>
|
||
) : (
|
||
<div className="stock-news-grid">
|
||
{activeNews.map((item) => (
|
||
<article
|
||
key={item.id ?? item.link}
|
||
className="stock-news-card"
|
||
>
|
||
<div className="stock-news-card__head">
|
||
<span className="stock-news-card__date">
|
||
{formatDate(item.published_at)}
|
||
</span>
|
||
{item.sentiment ? (
|
||
<span className="stock-chip">
|
||
{item.sentiment}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<p className="stock-news-card__title">
|
||
{item.title}
|
||
</p>
|
||
{item.summary && (
|
||
<p className="stock-news-card__summary">
|
||
{item.summary}
|
||
</p>
|
||
)}
|
||
{item.link && (
|
||
<a
|
||
href={item.link}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="stock-news-card__link"
|
||
>
|
||
원문 보기 →
|
||
</a>
|
||
)}
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Stock;
|