From 9ab45b64b6201746a92efe712f929e794314262e Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 27 Jan 2026 02:03:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A3=BC=EC=8B=9D=20=EB=A7=A4=EB=A7=A4=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.js | 12 +- src/pages/stock/Stock.css | 51 +++- src/pages/stock/Stock.jsx | 297 ++++++++++------------ src/pages/stock/StockTrade.jsx | 446 ++++++++++++++++----------------- 4 files changed, 394 insertions(+), 412 deletions(-) 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

-

Stock Lab

+

마켓 랩

+

주식 랩

- 매일 오전 8시에 주식 트렌드 기사를 스크랩하고 요약해, 거래 전 - 빠르게 흐름을 파악할 수 있게 구성했습니다. + 최신 시장 뉴스와 주요 지수 흐름을 한눈에 확인하세요.

- 잔고/주문 화면 + 거래 데스크 -
-

오늘의 상태

+

뉴스 요약

- Health - - {health.status} - -
-
- 최근 스크랩 - {formatDate(latestCrawled)} -
-
- 최근 발행 + 최신 발행 {formatDate(latestPublished)}
- 기사 수 - {combinedNews.length}건 + 국내 + {newsDomestic.length} +
+
+ 해외 + {newsOverseas.length}
- {health.message ? ( -

{health.message}

- ) : null}
- {error ?

{error}

: null} - {scrapMessage ? ( -

{scrapMessage}

- ) : null} -
-

Snapshot

-

시장 스냅샷

+

스냅샷

+

주요 지수

- 주요 지표의 현재가와 등락을 빠르게 확인합니다. + 주요 지수 값과 등락을 함께 확인합니다.

{indicesLoading ? ( - 갱신 중 - ) : null} - {indicesAt ? ( - {indicesAt} + 불러오는 중 ) : null}
{indicesError ? (

{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 (
{ {item.value ?? '--'} - {item.direction === 'red' - ? '▲' - : item.direction === 'blue' - ? '▼' - : '■'}{' '} - {item.change_value ?? '--'}{' '} - {item.change_percent ?? ''} + {changeText || '--'}
- )) + ); + }) )}
@@ -295,10 +249,10 @@ const Stock = () => {
-

Filter

+

필터

뉴스 필터

- 표시 개수를 조정해 빠르게 훑어볼 수 있습니다. + 표시할 뉴스 개수를 조정합니다.

@@ -313,13 +267,13 @@ const Stock = () => { > {[10, 20, 30, 40].map((value) => ( ))}

- 최신 기사부터 정렬됩니다. + 최신 뉴스가 먼저 표시됩니다.

@@ -328,20 +282,20 @@ const Stock = () => {
-

Headlines

-

트렌드 기사

+

헤드라인

+

시장 뉴스

- 스크랩된 뉴스 요약을 바로 확인할 수 있습니다. + Stock Lab API에서 최신 뉴스를 불러옵니다.

{loading ? ( 불러오는 중 ) : null} - - 국내 {newsDomestic.length} · 해외{' '} - {newsOverseas.length} - + + 국내 {newsDomestic.length} / 해외{' '} + {newsOverseas.length} +
@@ -350,7 +304,7 @@ const Stock = () => { ) : newsError ? (

{newsError}

) : combinedNews.length === 0 ? ( -

표시할 뉴스가 없습니다.

+

뉴스가 없습니다.

) : ( <>
@@ -363,7 +317,7 @@ const Stock = () => { }`} onClick={() => setNewsCategory('domestic')} > - 국내 뉴스 + 국내
- {newsCategory === 'overseas' && - newsOverseas.length === 0 ? ( -

해외 뉴스 없음

+ {activeNews.length === 0 ? ( +

+ 해당 카테고리 뉴스가 없습니다. +

) : (
- {(newsCategory === 'domestic' - ? newsDomestic - : newsOverseas - ).map((item) => ( + {activeNews.map((item) => (

{item.title}

-

- {item.summary} -

- {item.press} - {item.pub_date} + + {formatDate(item.published_at)} + + {item.sentiment ? ( + + {item.sentiment} + + ) : null} { @@ -10,19 +10,21 @@ const formatNumber = (value) => { return new Intl.NumberFormat('ko-KR').format(numeric); }; +const formatPercent = (value) => { + if (value === null || value === undefined || value === '') return '-'; + if (typeof value === 'string' && value.includes('%')) return value; + const numeric = Number(value); + if (Number.isNaN(numeric)) return value; + return `${numeric.toFixed(2)}%`; +}; + const StockTrade = () => { const [balance, setBalance] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); const [balanceError, setBalanceError] = useState(''); - const [orderForm, setOrderForm] = useState({ - code: '', - qty: 1, - price: 0, - type: 'buy', - }); - const [orderLoading, setOrderLoading] = useState(false); - const [orderMessage, setOrderMessage] = useState(''); - const [error, setError] = useState(''); + const [autoLoading, setAutoLoading] = useState(false); + const [autoError, setAutoError] = useState(''); + const [autoResult, setAutoResult] = useState(null); const loadBalance = async () => { setBalanceLoading(true); @@ -37,25 +39,20 @@ const StockTrade = () => { } }; - const onOrderSubmit = async (event) => { - event.preventDefault(); - setOrderLoading(true); - setOrderMessage(''); - setError(''); + const runAutoTrade = async () => { + setAutoLoading(true); + setAutoError(''); + setAutoResult(null); try { - const payload = { - code: orderForm.code.trim(), - qty: Number(orderForm.qty), - price: Number(orderForm.price), - type: orderForm.type, - }; - const result = await createTradeOrder(payload); - setOrderMessage(result?.message ?? '주문이 접수되었습니다.'); - await loadBalance(); + const result = await requestAutoTrade(); + setAutoResult(result); + if (result?.status === 'success') { + await loadBalance(); + } } catch (err) { - setError(err?.message ?? String(err)); + setAutoError(err?.message ?? String(err)); } finally { - setOrderLoading(false); + setAutoLoading(false); } }; @@ -63,244 +60,233 @@ const StockTrade = () => { loadBalance(); }, []); + const holdings = useMemo( + () => (Array.isArray(balance?.holdings) ? balance.holdings : []), + [balance] + ); + const summary = balance?.summary ?? {}; + const autoStatus = autoResult?.status ?? ''; + const decision = autoResult?.decision ?? null; + const tradeResult = autoResult?.trade_result ?? null; + return (
-

Trade Desk

-

Stock Trade

+

거래 데스크

+

주식 거래

- 잔고 확인과 매수/매도 주문을 한 화면에서 집중적으로 처리합니다. + 연결된 계좌 잔고를 확인하고 AI 자동 매매 판단을 + 요청하세요.

- 스톡 홈으로 + 주식 랩으로 돌아가기
-

거래 안내

+

계좌 요약

- 주문 유형 - 시장가/지정가 + 총 평가금액 + {formatNumber(summary.total_eval)}
- 시장가 - 가격 0 입력 + 예수금 + {formatNumber(summary.deposit)}
- 안내 - 주문 전 코드 확인 + 보유 종목 + {holdings.length}
+ {summary.note ? ( +

{summary.note}

+ ) : null}
- {error ?

{error}

: null} + {balanceError ?

{balanceError}

: null} + {autoError ?

{autoError}

: null}
-
-
-

Balance

-

잔고

-

- 보유 잔고와 보유 종목을 확인합니다. -

-
-
- {balanceLoading ? ( - 조회 중 - ) : null} - -
+
+
+

잔고

+

보유 현황

+

+ 연결 계좌의 실시간 잔고와 보유 종목을 확인합니다. +

- {balanceError ? ( -

{balanceError}

- ) : ( -
-
- {[ - { - label: '예수금', - value: - balance?.cash ?? - balance?.available_cash ?? - balance?.deposit, - }, - { - label: '총평가', - value: - balance?.total_eval ?? - balance?.total_value ?? - balance?.evaluation, - }, - { - label: '손익', - value: - balance?.pnl ?? - balance?.profit_loss ?? - balance?.total_pnl, - }, - ] - .filter((item) => item.value !== undefined) - .map((item) => ( -
- {item.label} - {formatNumber(item.value)} -
- ))} +
+ {balanceLoading ? ( + 조회 중 + ) : null} + +
+
+
+
+ {[ + { + label: '총 평가', + value: summary.total_eval, + }, + { + label: '예수금', + value: summary.deposit, + }, + ].map((item) => ( +
+ {item.label} + {formatNumber(item.value)}
- {Array.isArray( - balance?.holdings ?? - balance?.positions ?? - balance?.items - ) && - (balance?.holdings ?? - balance?.positions ?? - balance?.items).length ? ( -
- {(balance?.holdings ?? - balance?.positions ?? - balance?.items - ).map((item, idx) => ( -
-
-

- {item.name ?? item.code ?? '종목'} -

- - {item.code ?? item.symbol ?? ''} - -
-
- 수량 - - {formatNumber( - item.qty ?? - item.quantity ?? - item.holding - )} - -
-
- 평균단가 - - {formatNumber( - item.avg_price ?? - item.avg ?? - item.price - )} - -
-
- ))} + ))} +
+ {holdings.length ? ( +
+ {holdings.map((item, idx) => ( +
+
+

+ {item.name ?? item.code ?? 'N/A'} +

+ + {item.code ?? ''} + +
+
+ 수량 + + {formatNumber(item.qty)} + +
+
+ 매입가 + + {formatNumber(item.buy_price)} + +
+
+ 현재가 + + {formatNumber(item.current_price)} + +
+
+ 수익률 + + {formatPercent(item.profit_rate)} + +
- ) : ( -

보유 종목 없음

- )} + ))}
+ ) : ( +

보유 종목이 없습니다.

)} +
-
-
-

Order

-

주문 (매수/매도)

-

- 종목 코드, 수량, 가격을 입력해 주문을 전송합니다. -

-
+
+
+

자동 매매

+

AI 매매 판단

+

+ 분석에 몇 초 걸릴 수 있습니다. 결과는 아래에 + 표시됩니다. +

-
- - - - - - {orderMessage ? ( -

{orderMessage}

+
+ {autoLoading ? ( + 분석 중 ) : null} - + +
+
+
+ {!autoResult ? ( +

+ 아직 자동 매매 요청이 없습니다. +

+ ) : autoStatus === 'failed_parse' ? ( +
+

원문 응답

+
+                                {autoResult?.raw_response ??
+                                    '원문 응답이 없습니다.'}
+                            
+
+ ) : ( +
+
+

판단

+
+
+ 액션 + {decision?.action ?? '-'} +
+
+ 종목코드 + {decision?.ticker ?? '-'} +
+
+ 수량 + + {formatNumber(decision?.quantity)} + +
+
+ {decision?.reason ? ( +

+ {decision.reason} +

+ ) : null} +
+
+

주문 결과

+
+
+ 상태 + + {tradeResult?.success === true + ? '성공' + : tradeResult?.success === false + ? '실패' + : '대기'} + +
+
+ 주문번호 + + {tradeResult?.order_no ?? '-'} + +
+
+
+
+ )} +
);