diff --git a/src/api.js b/src/api.js index a357b85..a080079 100644 --- a/src/api.js +++ b/src/api.js @@ -99,6 +99,6 @@ export function getTradeBalance() { return apiGet("/api/trade/balance"); } -export function requestAutoTrade(payload) { - return apiPost("/api/trade/auto", payload); +export function createTradeOrder(payload) { + return apiPost("/api/trade/order", payload); } diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css index 90655d4..ef70c0f 100644 --- a/src/pages/stock/Stock.css +++ b/src/pages/stock/Stock.css @@ -45,6 +45,15 @@ gap: 14px; } +.stock-ideas { + margin: 0; + padding-left: 18px; + color: var(--muted); + font-size: 13px; + display: grid; + gap: 6px; +} + .stock-card__title { margin: 0; font-weight: 600; @@ -133,6 +142,18 @@ grid-column: 1 / -1; } +.stock-filter-row { + display: grid; + gap: 18px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + align-items: stretch; +} + +.stock-filter-row .stock-panel { + width: 100%; + max-width: none; +} + .stock-panel__head { display: flex; justify-content: space-between; @@ -367,44 +388,66 @@ border-radius: 12px; padding: 10px; display: grid; - grid-template-columns: minmax(0, 1.2fr) repeat(4, minmax(0, 0.6fr)); + grid-template-columns: minmax(0, 1.2fr) repeat(5, minmax(0, 0.6fr)); gap: 10px; - font-size: 12px; + font-size: 13px; color: var(--muted); background: rgba(255, 255, 255, 0.02); + align-items: center; } .stock-holdings__name { margin: 0; font-weight: 600; color: var(--text); + font-size: 14px; } .stock-holdings__code { + font-size: 12px; +} + +.stock-holdings__metric { + display: grid; + gap: 4px; + justify-items: start; +} + +.stock-holdings__metric span { font-size: 11px; + color: var(--muted); } -.stock-ai { - display: grid; - gap: 12px; +.stock-holdings__metric strong { + font-size: 14px; + color: var(--text); } -.stock-ai__grid { - display: grid; - gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +.stock-profit { + color: var(--text); } -.stock-ai__card { +.stock-profit.is-up { + color: #f3a7a7; +} + +.stock-profit.is-down { + color: #9fc5ff; +} + +.stock-profit.is-flat { + color: var(--muted); +} + +.stock-result { border: 1px solid var(--line); border-radius: 14px; padding: 12px; - display: grid; - gap: 10px; background: rgba(0, 0, 0, 0.2); + margin-top: 10px; } -.stock-ai__title { +.stock-result__title { margin: 0; font-size: 12px; text-transform: uppercase; @@ -412,22 +455,53 @@ color: var(--muted); } -.stock-ai__reason { - margin: 0; +.stock-result pre { + margin: 8px 0 0; + white-space: pre-wrap; font-size: 12px; color: var(--muted); - line-height: 1.4; } -.stock-ai__raw { +.stock-modal { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; +} + +.stock-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); +} + +.stock-modal__card { + position: relative; + width: min(520px, 90vw); border: 1px solid var(--line); - border-radius: 14px; - padding: 12px; - background: rgba(0, 0, 0, 0.2); + border-radius: 16px; + background: var(--surface); + padding: 16px; + display: grid; + gap: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); } -.stock-ai__raw pre { - margin: 8px 0 0; +.stock-modal__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.stock-modal__head h4 { + margin: 0; + font-size: 16px; +} + +.stock-modal pre { + margin: 0; white-space: pre-wrap; font-size: 12px; color: var(--muted); diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx index bbaa271..2749326 100644 --- a/src/pages/stock/Stock.jsx +++ b/src/pages/stock/Stock.jsx @@ -30,7 +30,7 @@ const normalizeIndices = (data) => { if (Array.isArray(data)) { return data.map((item) => ({ - name: item?.name ?? '-', + name: item?.name ?? item?.key ?? '-', value: item?.value ?? '-', change: item?.change_value ?? item?.change ?? '', percent: item?.change_percent ?? item?.percent ?? '', @@ -40,7 +40,7 @@ const normalizeIndices = (data) => { if (Array.isArray(data?.indices)) { return data.indices.map((item) => ({ - name: item?.name ?? '-', + name: item?.name ?? item?.key ?? '-', value: item?.value ?? '-', change: item?.change_value ?? item?.change ?? '', percent: item?.change_percent ?? item?.percent ?? '', @@ -146,9 +146,10 @@ const Stock = () => { 'KOSPI', 'KOSDAQ', 'KOSPI200', - 'USD/KRW', - 'NASDAQ', + '다우산업', + '나스닥', 'S&P500', + '원달러 환율', ]; const sortedIndices = [...indices].sort((a, b) => { const aIndex = indexOrder.indexOf(a.name); @@ -158,7 +159,7 @@ const Stock = () => { } return a.name.localeCompare(b.name); }); - const highlighted = new Set(['KOSPI', 'KOSDAQ', 'USD/KRW']); + const highlighted = new Set(['KOSPI', 'KOSDAQ', '원달러 환율']); const activeNews = newsCategory === 'domestic' ? newsDomestic : newsOverseas; @@ -185,97 +186,85 @@ const Stock = () => {
-

뉴스 요약

-
-
- 최신 발행 - {formatDate(latestPublished)} -
-
- 국내 - {newsDomestic.length} -
-
- 해외 - {newsOverseas.length} -
-
+

다음 업데이트 아이디어

+
-
-
-
-
-

스냅샷

-

주요 지수

-

- 주요 지수 값과 등락을 함께 확인합니다. -

-
-
- {indicesLoading ? ( - 불러오는 중 - ) : null} - -
+
+
+
+

스냅샷

+

주요 지수

+

+ 주요 지수 값과 등락을 함께 확인합니다. +

-
- {indicesError ? ( -

{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 || '--'} - -
- ); - }) - )} +
+ {indicesLoading ? ( + 불러오는 중 + ) : null} +
+
+ {indicesError ? ( +

{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 || '--'} + +
+ ); + }) + )} +
+
-
+
+

필터

@@ -306,6 +295,31 @@ const Stock = () => {

+
+
+
+

요약

+

뉴스 요약

+

+ 최신 발행 시각과 기사 수를 확인합니다. +

+
+
+
+
+ 최신 발행 + {formatDate(latestPublished)} +
+
+ 국내 + {newsDomestic.length} +
+
+ 해외 + {newsOverseas.length} +
+
+
diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx index f4da5b8..1554bbb 100644 --- a/src/pages/stock/StockTrade.jsx +++ b/src/pages/stock/StockTrade.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { getTradeBalance, requestAutoTrade } from '../../api'; +import { createTradeOrder, getTradeBalance } from '../../api'; import './Stock.css'; const formatNumber = (value) => { @@ -29,6 +29,7 @@ const getBuyPrice = (item) => item?.buy_price, item?.avg_price, item?.avg, + item?.purchase_price, item?.buyPrice, item?.price ); @@ -49,16 +50,33 @@ const getProfitRate = (item) => item?.profit_pct, item?.profitPercent, item?.pnl_rate, - item?.return_rate + item?.return_rate, + item?.yield ); +const getProfitLoss = (item) => + pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss); + +const toNumeric = (value) => { + if (value === null || value === undefined || value === '') return null; + const numeric = Number(String(value).replace(/[^0-9.-]/g, '')); + return Number.isNaN(numeric) ? null : numeric; +}; + const StockTrade = () => { const [balance, setBalance] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); const [balanceError, setBalanceError] = useState(''); - const [autoLoading, setAutoLoading] = useState(false); - const [autoError, setAutoError] = useState(''); - const [autoResult, setAutoResult] = useState(null); + const [manualForm, setManualForm] = useState({ + code: '', + qty: 1, + price: 0, + type: 'buy', + }); + const [manualLoading, setManualLoading] = useState(false); + const [manualError, setManualError] = useState(''); + const [manualResult, setManualResult] = useState(null); + const [kisModal, setKisModal] = useState(''); const loadBalance = async () => { setBalanceLoading(true); @@ -73,20 +91,32 @@ const StockTrade = () => { } }; - const runAutoTrade = async () => { - setAutoLoading(true); - setAutoError(''); - setAutoResult(null); + const submitManualOrder = async (event) => { + event.preventDefault(); + setManualLoading(true); + setManualError(''); + setManualResult(null); try { - const result = await requestAutoTrade(); - setAutoResult(result); - if (result?.status === 'success' || result?.status === 'completed') { - await loadBalance(); + const payload = { + ticker: manualForm.code.trim(), + action: manualForm.type === 'sell' ? 'SELL' : 'BUY', + quantity: Number(manualForm.qty), + price: Number(manualForm.price), + }; + const result = await createTradeOrder(payload); + setManualResult(result ?? { ok: true }); + if (result?.kis_result !== undefined) { + const message = + typeof result.kis_result === 'string' + ? result.kis_result + : JSON.stringify(result.kis_result, null, 2); + setKisModal(message); } + await loadBalance(); } catch (err) { - setAutoError(err?.message ?? String(err)); + setManualError(err?.message ?? String(err)); } finally { - setAutoLoading(false); + setManualLoading(false); } }; @@ -104,23 +134,8 @@ const StockTrade = () => { const summary = balance?.summary ?? {}; const totalEval = summary.total_eval ?? balance?.total_eval ?? balance?.total_value; - const deposit = summary.deposit ?? balance?.deposit ?? balance?.available_cash; - const autoStatus = autoResult?.status ?? ''; - const decision = autoResult?.decision ?? autoResult?.ai_response ?? null; - const tradeResult = autoResult?.trade_result ?? null; - const execution = autoResult?.execution ?? tradeResult?.execution ?? ''; - const statusLabel = - tradeResult?.success === true - ? '성공' - : tradeResult?.success === false - ? '실패' - : autoStatus === 'completed' - ? '완료' - : autoStatus === 'success' - ? '성공' - : autoStatus - ? autoStatus - : '대기'; + const deposit = + summary.deposit ?? balance?.deposit ?? balance?.available_cash; return (
@@ -129,8 +144,7 @@ const StockTrade = () => {

거래 데스크

주식 거래

- 연결된 계좌 잔고를 확인하고 AI 자동 매매 판단을 - 요청하세요. + 연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.

@@ -161,7 +175,6 @@ const StockTrade = () => { {balanceError ?

{balanceError}

: null} - {autoError ?

{autoError}

: null}
@@ -208,45 +221,79 @@ const StockTrade = () => {
{holdings.length ? (
- {holdings.map((item, idx) => ( -
-
-

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

- - {item.code ?? ''} - + {holdings.map((item, idx) => { + const profitLoss = getProfitLoss(item); + const profitLossNumeric = toNumeric(profitLoss); + const profitClass = + profitLossNumeric > 0 + ? 'is-up' + : profitLossNumeric < 0 + ? 'is-down' + : profitLossNumeric === 0 + ? 'is-flat' + : ''; + const profitRate = getProfitRate(item); + const profitRateNumeric = toNumeric(profitRate); + const profitRateClass = + profitRateNumeric > 0 + ? 'is-up' + : profitRateNumeric < 0 + ? 'is-down' + : profitRateNumeric === 0 + ? 'is-flat' + : ''; + return ( +
+
+

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

+ + {item.code ?? ''} + +
+
+ 수량 + + {formatNumber(getQty(item))} + +
+
+ 매입가 + + {formatNumber(getBuyPrice(item))} + +
+
+ 현재가 + + {formatNumber( + getCurrentPrice(item) + )} + +
+
+ 수익률 + + {formatPercent(profitRate)} + +
+
+ 평가손익 + + {formatNumber(profitLoss)} + +
-
- 수량 - - {formatNumber(getQty(item))} - -
-
- 매입가 - - {formatNumber(getBuyPrice(item))} - -
-
- 현재가 - - {formatNumber(getCurrentPrice(item))} - -
-
- 수익률 - - {formatPercent(getProfitRate(item))} - -
-
- ))} + ); + })}
) : (

보유 종목이 없습니다.

@@ -257,94 +304,119 @@ const StockTrade = () => {
-

자동 매매

-

AI 매매 판단

+

수동 주문

+

직접 매수/매도

- 분석에 몇 초 걸릴 수 있습니다. 결과는 아래에 - 표시됩니다. + 종목명 또는 종목코드를 입력하고 매수/매도 주문을 + 요청합니다.

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

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

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

원문 응답

+
+ + + + + + {manualError ? ( +

{manualError}

+ ) : null} + {manualResult ? ( +
+

요청 결과

-                                {autoResult?.raw_response ??
-                                    '원문 응답이 없습니다.'}
+                                {typeof manualResult === 'string'
+                                    ? manualResult
+                                    : JSON.stringify(manualResult, null, 2)}
                             
- ) : ( -
-
-

판단

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

- {decision.reason} -

- ) : null} -
-
-

주문 결과

-
-
- 상태 - {statusLabel} -
- {execution ? ( -
- 실행 - {execution} -
- ) : null} -
- 주문번호 - - {tradeResult?.order_no ?? '-'} - -
-
-
-
- )} -
+ ) : null} +
+ {kisModal ? ( +
+
setKisModal('')} + /> +
+
+

주문 결과

+ +
+
{kisModal}
+
+
+ ) : null}
); };