{item.title}
-- {item.summary} -
diff --git a/src/api.js b/src/api.js index 2b32633..7599cf3 100644 --- a/src/api.js +++ b/src/api.js @@ -66,14 +66,6 @@ export function getStockNews(limit = 20, category) { return apiGet(`/api/stock/news?${qs.toString()}`); } -export function triggerStockScrap() { - return apiPost("/api/stock/scrap"); -} - -export function getStockHealth() { - return apiGet("/api/stock/health"); -} - export function getStockIndices() { return apiGet("/api/stock/indices"); } @@ -82,6 +74,6 @@ export function getTradeBalance() { return apiGet("/api/trade/balance"); } -export function createTradeOrder(payload) { - return apiPost("/api/trade/order", payload); +export function requestAutoTrade(payload) { + return apiPost("/api/trade/auto", payload); } diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index 96c4bd1..90655d4 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -367,7 +367,7 @@ border-radius: 12px; padding: 10px; display: grid; - grid-template-columns: minmax(0, 1.1fr) repeat(2, minmax(0, 0.7fr)); + grid-template-columns: minmax(0, 1.2fr) repeat(4, minmax(0, 0.6fr)); gap: 10px; font-size: 12px; color: var(--muted); @@ -384,6 +384,55 @@ font-size: 11px; } +.stock-ai { + display: grid; + gap: 12px; +} + +.stock-ai__grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.stock-ai__card { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + display: grid; + gap: 10px; + background: rgba(0, 0, 0, 0.2); +} + +.stock-ai__title { + margin: 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--muted); +} + +.stock-ai__reason { + margin: 0; + font-size: 12px; + color: var(--muted); + line-height: 1.4; +} + +.stock-ai__raw { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + background: rgba(0, 0, 0, 0.2); +} + +.stock-ai__raw pre { + margin: 8px 0 0; + white-space: pre-wrap; + font-size: 12px; + color: var(--muted); +} + .stock-order { display: grid; gap: 10px; diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx index d79f329..348550b 100644 --- a/src/pages/stock/Stock.jsx +++ b/src/pages/stock/Stock.jsx @@ -1,19 +1,18 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { - getStockHealth, - getStockIndices, - getStockNews, - triggerStockScrap, -} from '../../api'; +import { getStockIndices, getStockNews } from '../../api'; import './Stock.css'; -const formatDate = (value) => value ?? '-'; +const formatDate = (value) => { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('sv-SE'); +}; const toDateValue = (value) => { if (!value) return null; - const normalized = value.replace(' ', 'T').replace(/\./g, '-'); - const date = new Date(normalized); + const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; }; @@ -26,36 +25,52 @@ const getLatestBy = (items, key) => { return filtered[0]?.[key] ?? null; }; +const normalizeIndices = (data) => { + if (!data || typeof data !== 'object' || Array.isArray(data)) return []; + return Object.entries(data) + .filter(([, value]) => value && typeof value === 'object') + .map(([name, value]) => ({ + name, + value: value?.value ?? '-', + change: value?.change ?? '', + percent: value?.percent ?? '', + })); +}; + +const getDirection = (change, percent) => { + const pick = (value) => + value === undefined || value === null || value === '' ? null : value; + const raw = pick(change) ?? pick(percent); + if (!raw) return ''; + const str = String(raw).trim(); + if (str.startsWith('-')) return 'down'; + if (str.startsWith('+')) return 'up'; + const numeric = Number(str.replace(/[^0-9.-]/g, '')); + if (Number.isFinite(numeric)) { + if (numeric > 0) return 'up'; + if (numeric < 0) return 'down'; + } + return ''; +}; + 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 [scraping, setScraping] = useState(false); - const [error, setError] = useState(''); const [newsError, setNewsError] = useState(''); const [indicesError, setIndicesError] = useState(''); - const [scrapMessage, setScrapMessage] = useState(''); - const [health, setHealth] = useState({ - status: 'unknown', - message: '', - }); const [indices, setIndices] = useState([]); const [indicesLoading, setIndicesLoading] = useState(false); - const [indicesAt, setIndicesAt] = useState(''); const [autoRefreshMs] = useState(180000); const combinedNews = useMemo( () => [...newsDomestic, ...newsOverseas], [newsDomestic, newsOverseas] ); - const latestCrawled = useMemo( - () => getLatestBy(combinedNews, 'crawled_at'), - [combinedNews] - ); const latestPublished = useMemo( - () => getLatestBy(combinedNews, 'pub_date'), + () => getLatestBy(combinedNews, 'published_at'), [combinedNews] ); @@ -76,32 +91,12 @@ const Stock = () => { } }; - const loadHealth = async () => { - try { - const data = await getStockHealth(); - setHealth({ - status: data?.ok ? 'ok' : 'warn', - message: data?.message ?? '', - }); - } catch (err) { - const rawMessage = err?.message ?? String(err); - const message = rawMessage.includes('404') - ? '헬스 체크 엔드포인트가 아직 준비되지 않았습니다.' - : rawMessage; - setHealth({ - status: 'unknown', - message, - }); - } - }; - const loadIndices = async () => { setIndicesLoading(true); setIndicesError(''); try { const data = await getStockIndices(); - setIndices(Array.isArray(data?.indices) ? data.indices : []); - setIndicesAt(data?.crawled_at ?? ''); + setIndices(normalizeIndices(data)); } catch (err) { setIndicesError(err?.message ?? String(err)); } finally { @@ -109,46 +104,44 @@ const Stock = () => { } }; - const onScrap = async () => { - setScraping(true); - setError(''); - setScrapMessage(''); - try { - const result = await triggerStockScrap(); - if (!result?.ok) { - throw new Error('스크랩 요청이 실패했습니다.'); - } - setScrapMessage('스크랩 요청 완료'); - await loadNews(); - } catch (err) { - setError(err?.message ?? String(err)); - } finally { - setScraping(false); - } - }; - useEffect(() => { loadNews(); }, [limit]); useEffect(() => { - loadHealth(); loadIndices(); - const timer = window.setInterval(() => { - loadIndices(); - }, autoRefreshMs); + const timer = window.setInterval(loadIndices, autoRefreshMs); return () => window.clearInterval(timer); }, [autoRefreshMs]); + const indexOrder = [ + 'KOSPI', + 'KOSDAQ', + 'KOSPI200', + 'USD/KRW', + 'NASDAQ', + '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', 'USD/KRW']); + const activeNews = + newsCategory === 'domestic' ? newsDomestic : newsOverseas; + return (
Market Lab
-마켓 랩
+- 매일 오전 8시에 주식 트렌드 기사를 스크랩하고 요약해, 거래 전 - 빠르게 흐름을 파악할 수 있게 구성했습니다. + 최신 시장 뉴스와 주요 지수 흐름을 한눈에 확인하세요.
오늘의 상태
+뉴스 요약
{health.message}
- ) : null}{error}
: null} - {scrapMessage ? ( -{scrapMessage}
- ) : null} -Snapshot
-스냅샷
+- 주요 지표의 현재가와 등락을 빠르게 확인합니다. + 주요 지수 값과 등락을 함께 확인합니다.
{indicesError}
- ) : indices.length === 0 ? ( -지표 데이터를 불러오지 못했습니다.
+ ) : sortedIndices.length === 0 ? ( ++ 지수 데이터가 없습니다. +
) : ( - [...indices] - .sort((a, b) => { - const typeOrder = ['domestic', 'overseas']; - const order = { - domestic: ['KOSPI', 'KOSDAQ', 'KOSPI200'], - overseas: ['NASDAQ', 'NAS', 'S&P500'], - }; - const aTypeIdx = typeOrder.indexOf(a.type); - const bTypeIdx = typeOrder.indexOf(b.type); - if (aTypeIdx !== bTypeIdx) { - return ( - (aTypeIdx === -1 ? 99 : aTypeIdx) - - (bTypeIdx === -1 ? 99 : bTypeIdx) - ); - } - const aOrder = - order[a.type]?.indexOf(a.name) ?? 999; - const bOrder = - order[b.type]?.indexOf(b.name) ?? 999; - return aOrder - bOrder; - }) - .map((item) => ( + sortedIndices.map((item) => { + const direction = getDirection( + item.change, + item.percent + ); + const changeText = [ + item.change, + item.percent, + ] + .filter(Boolean) + .join(' '); + return (Filter
+필터
- 표시 개수를 조정해 빠르게 훑어볼 수 있습니다. + 표시할 뉴스 개수를 조정합니다.
- 최신 기사부터 정렬됩니다. + 최신 뉴스가 먼저 표시됩니다.
Headlines
-헤드라인
+- 스크랩된 뉴스 요약을 바로 확인할 수 있습니다. + Stock Lab API에서 최신 뉴스를 불러옵니다.
{newsError}
) : combinedNews.length === 0 ? ( -표시할 뉴스가 없습니다.
+뉴스가 없습니다.
) : ( <>해외 뉴스 없음
+ {activeNews.length === 0 ? ( ++ 해당 카테고리 뉴스가 없습니다. +
) : (