Files
web-page/src/pages/stock/Stock.jsx

566 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">시장 탐욕·공포 지수 (0100)</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;