{item.title}
{item.summary && ({item.summary}
)} {item.link && ( 원문 보기 → )}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 (
마켓 랩
최신 시장 뉴스와 주요 지수 흐름을 한눈에 확인하세요.
다음 업데이트 아이디어
스냅샷
주요 지수 값과 등락을 함께 확인합니다.
{indicesError}
) : sortedIndices.length === 0 ? (지수 데이터가 없습니다.
) : ( sortedIndices.map((item) => { const direction = getDirection( item.change, item.percent, item.direction ); const changeText = [item.change, item.percent] .filter(Boolean) .join(' '); return ({item.name}
{item.value ?? '--'} {changeText || '--'}심리 지표
시장 탐욕·공포 지수 (0–100)
데이터 없음
)}변동성 지수
CBOE 공포 지수
{getVixLevel(vixData.value ?? 0).label}
{vixData.change != null && (= 0 ? 'is-up' : 'is-down'}`}> {vixData.change >= 0 ? '+' : ''}{vixData.change} {vixData.changePercent != null && ` (${vixData.changePercent >= 0 ? '+' : ''}${vixData.changePercent}%)`}
)}{level.desc}
데이터 없음
)}글로벌 매크로
금리·원자재 등 주요 거시경제 지표를 확인합니다.
미국 10년물 국채 금리
= 0 ? 'is-up' : 'is-down'}`}> {macroData.treasury.change >= 0 ? '+' : ''}{macroData.treasury.change} {macroData.treasury.changePercent != null && ` (${macroData.treasury.changePercent >= 0 ? '+' : ''}${macroData.treasury.changePercent}%)`}
)}금리 상승 시 주식 밸류에이션 압박. 4% 이상 지속은 주식 하락 압력 신호. 단기 급등은 인플레이션 우려를 반영합니다.
WTI 유가
= 0 ? 'is-up' : 'is-down'}`}> {macroData.wti.change >= 0 ? '+' : ''}{macroData.wti.change} {macroData.wti.changePercent != null && ` (${macroData.wti.changePercent >= 0 ? '+' : ''}${macroData.wti.changePercent}%)`}
)}에너지 인플레이션 지표. $80 이상 지속 시 물가 상승 우려 확대. 급락은 경기침체 가능성을 반영하기도 합니다.
Brent 유가
= 0 ? 'is-up' : 'is-down'}`}> {macroData.brent.change >= 0 ? '+' : ''}{macroData.brent.change} {macroData.brent.changePercent != null && ` (${macroData.brent.changePercent >= 0 ? '+' : ''}${macroData.brent.changePercent}%)`}
)}국제 기준 유가. WTI와 함께 에너지 시장 방향을 파악하는 데 활용. 지정학 리스크 시 WTI 대비 프리미엄 형성.
시장 건강
백엔드 API 연동 후 실시간 데이터를 표시합니다.
ADR (등락주선 비율)
일정 기간 상승종목 ÷ (상승+하락) 종목 비율. 0.5 이상 = 폭넓은 상승장. 0.3 이하 = 일부 대형주만 오르는 약세 신호.
GET /api/stock/adr
고객예탁금 / 신용융자
고객예탁금 증가 = 투자 대기자금 유입 = 강세. 신용융자 급증 = 과열 경고. 예탁금 감소 + 신용 급증 = 위험 구간.
GET /api/stock/deposit
헤드라인
Stock Lab API에서 최신 뉴스를 불러옵니다.
{newsError}
) : combinedNews.length === 0 ? (뉴스가 없습니다.
) : ( <>해당 카테고리 뉴스가 없습니다.
) : ({item.title}
{item.summary && ({item.summary}
)} {item.link && ( 원문 보기 → )}