import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { createTradeOrder, getTradeBalance, getPortfolio, addPortfolio, updatePortfolio, deletePortfolio, } from '../../api'; import Loading from '../../components/Loading'; import './Stock.css'; /* ── helpers ─────────────────────────────────────────────────────── */ const formatNumber = (value) => { if (value === null || value === undefined || value === '') return '-'; const numeric = Number(value); if (Number.isNaN(numeric)) return 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 >= 0 ? '+' : ''}${numeric.toFixed(2)}%`; }; const pickFirst = (...values) => values.find((value) => value !== undefined && value !== null && value !== ''); const getQty = (item) => pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty); const getBuyPrice = (item) => pickFirst( item?.buy_price, item?.avg_price, item?.avg, item?.purchase_price, item?.buyPrice, item?.price ); const getCurrentPrice = (item) => pickFirst( item?.current_price, item?.current, item?.cur_price, item?.now_price, item?.market_price ); const getProfitRate = (item) => pickFirst( item?.profit_rate, item?.profitRate, item?.profit_pct, item?.profitPercent, item?.pnl_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 profitColorClass = (numericValue) => { if (numericValue > 0) return 'is-up'; if (numericValue < 0) return 'is-down'; if (numericValue === 0) return 'is-flat'; return ''; }; /* ── empty portfolio form ────────────────────────────────────────── */ const emptyPortfolioForm = { broker: '', ticker: '', name: '', quantity: '', avg_price: '', }; /* ── component ───────────────────────────────────────────────────── */ const StockTrade = () => { /* Balance state */ const [balance, setBalance] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); const [balanceError, setBalanceError] = useState(''); /* Portfolio state */ const [portfolio, setPortfolio] = useState(null); const [portfolioLoading, setPortfolioLoading] = useState(false); const [portfolioError, setPortfolioError] = useState(''); /* Portfolio add form */ const [addForm, setAddForm] = useState({ ...emptyPortfolioForm }); const [addFormOpen, setAddFormOpen] = useState(false); const [addLoading, setAddLoading] = useState(false); const [addError, setAddError] = useState(''); /* Portfolio edit */ const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({}); const [editLoading, setEditLoading] = useState(false); const editOrigRef = useRef({}); /* Portfolio delete */ const [deleteConfirmId, setDeleteConfirmId] = useState(null); /* Manual order state */ 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(''); /* ── loaders ─────────────────────────────────────────────────── */ const loadBalance = useCallback(async () => { setBalanceLoading(true); setBalanceError(''); try { const data = await getTradeBalance(); setBalance(data); } catch (err) { setBalanceError(err?.message ?? String(err)); } finally { setBalanceLoading(false); } }, []); const loadPortfolio = useCallback(async () => { setPortfolioLoading(true); setPortfolioError(''); try { const data = await getPortfolio(); setPortfolio(data); } catch (err) { setPortfolioError(err?.message ?? String(err)); } finally { setPortfolioLoading(false); } }, []); useEffect(() => { loadBalance(); loadPortfolio(); }, [loadBalance, loadPortfolio]); /* Auto-refresh portfolio every 3 min */ useEffect(() => { const timer = window.setInterval(loadPortfolio, 180000); return () => window.clearInterval(timer); }, [loadPortfolio]); /* ── portfolio actions ───────────────────────────────────────── */ const handleAddSubmit = async (e) => { e.preventDefault(); setAddLoading(true); setAddError(''); try { await addPortfolio({ broker: addForm.broker.trim(), ticker: addForm.ticker.trim(), name: addForm.name.trim(), quantity: Number(addForm.quantity), avg_price: Number(addForm.avg_price), }); setAddForm({ ...emptyPortfolioForm }); setAddFormOpen(false); await loadPortfolio(); } catch (err) { setAddError(err?.message ?? String(err)); } finally { setAddLoading(false); } }; const handleEditStart = (item) => { setEditingId(item.id); setEditForm({ quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name, }); /* 원본 값을 기억해서 diff 비교용 */ editOrigRef.current = { quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name, }; }; const handleEditSave = async (id) => { setEditLoading(true); try { /* 변경된 필드만 추출하여 부분 수정 */ const orig = editOrigRef.current ?? {}; const diff = {}; for (const key of Object.keys(editForm)) { if (editForm[key] !== orig[key]) { diff[key] = editForm[key]; } } if (Object.keys(diff).length === 0) { setEditingId(null); return; } await updatePortfolio(id, diff); setEditingId(null); await loadPortfolio(); } catch (err) { const msg = err?.message ?? String(err); if (msg.includes('404') || msg.includes('not found')) { alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); await loadPortfolio(); } else { alert('수정 실패: ' + msg); } } finally { setEditLoading(false); } }; const handleDelete = async (id) => { try { await deletePortfolio(id); setDeleteConfirmId(null); await loadPortfolio(); } catch (err) { const msg = err?.message ?? String(err); if (msg.includes('404') || msg.includes('not found')) { alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); setDeleteConfirmId(null); await loadPortfolio(); } else { alert('삭제 실패: ' + msg); } } }; /* ── manual order ────────────────────────────────────────────── */ const submitManualOrder = async (event) => { event.preventDefault(); setManualLoading(true); setManualError(''); setManualResult(null); try { 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) { setManualError(err?.message ?? String(err)); } finally { setManualLoading(false); } }; /* ── derived data ────────────────────────────────────────────── */ const holdings = useMemo(() => { if (!balance) return []; if (Array.isArray(balance.holdings)) return balance.holdings; if (Array.isArray(balance.positions)) return balance.positions; if (Array.isArray(balance.items)) return balance.items; return []; }, [balance]); const summary = balance?.summary ?? {}; const totalEval = summary.total_eval ?? balance?.total_eval ?? balance?.total_value; const deposit = summary.deposit ?? balance?.deposit ?? balance?.available_cash; /* Portfolio grouped by broker */ const portfolioHoldings = portfolio?.holdings ?? []; const portfolioSummary = portfolio?.summary ?? {}; const brokerGroups = useMemo(() => { const map = {}; for (const item of portfolioHoldings) { const broker = item.broker || '기타'; if (!map[broker]) map[broker] = []; map[broker].push(item); } return Object.entries(map).sort(([a], [b]) => a.localeCompare(b)); }, [portfolioHoldings]); /* broker-level summary (eval_amount가 null인 종목은 안전하게 건너뜀) */ const getBrokerSummary = (items) => { let totalBuy = 0; let totalEvalAmt = 0; let hasNullPrice = false; for (const item of items) { totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0); if (item.eval_amount != null) { totalEvalAmt += item.eval_amount; } else { hasNullPrice = true; } } const totalProfit = totalEvalAmt - totalBuy; const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0; return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice }; }; /* ── broker color accents (deterministic from name) ──────────── */ const brokerColors = useMemo(() => { const palette = [ { border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' }, { border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' }, { border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' }, { border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' }, { border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' }, { border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' }, ]; const map = {}; brokerGroups.forEach(([broker], i) => { map[broker] = palette[i % palette.length]; }); return map; }, [brokerGroups]); /* ── render ───────────────────────────────────────────────────── */ return (

거래 데스크

주식 거래

연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.

주식 랩으로 돌아가기

계좌 요약

총 평가금액 {formatNumber(totalEval)}
예수금 {formatNumber(deposit)}
보유 종목 {holdings.length}
{portfolioHoldings.length > 0 && ( <>
포트폴리오 종목 {portfolioHoldings.length}
포트폴리오 평가 {formatNumber(portfolioSummary.total_eval)}
포트폴리오 손익 {formatNumber(portfolioSummary.total_profit)} {portfolioSummary.total_profit_rate != null && ( ({formatPercent(portfolioSummary.total_profit_rate)}) )}
)}
{summary.note ? (

{summary.note}

) : null}
{/* ── Portfolio sections (broker별) ─────────────────────── */} {portfolioError ? (

{portfolioError}

) : null} {/* 총 포트폴리오 요약 + 종목 추가 */}

포트폴리오

수동 입력 종목 관리

증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)

{portfolioLoading ? ( ) : null}
{/* Add form */} {addFormOpen && (
{addError &&

{addError}

}
)} {/* Portfolio total summary */} {portfolioHoldings.length > 0 && (
{[ { label: '총 매입', value: portfolioSummary.total_buy }, { label: '총 평가', value: portfolioSummary.total_eval }, { label: '총 손익', value: portfolioSummary.total_profit, isProfit: true }, { label: '수익률', value: portfolioSummary.total_profit_rate, isRate: true }, ].map((s) => (
{s.label} {s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
))}
)}
{/* Each broker gets a stacked card */} {brokerGroups.map(([broker, items]) => { const bSummary = getBrokerSummary(items); const color = brokerColors[broker]; return (

{broker}

{broker} 보유 현황

{items.length}종목 · 평가{' '} {formatNumber(bSummary.totalEval)} · 손익{' '} {formatNumber(bSummary.totalProfit)} ( {formatPercent(bSummary.totalProfitRate)})

{items.map((item) => { const profitAmt = item.profit_amount; const profitRate = item.profit_rate; const profitAmtN = toNumeric(profitAmt); const profitRateN = toNumeric(profitRate); const isEditing = editingId === item.id; const isDeleting = deleteConfirmId === item.id; return (
{isEditing ? ( /* Edit mode */
) : ( /* Normal display */ <>

{item.name ?? item.ticker ?? 'N/A'}

{item.ticker ?? ''}
수량 {formatNumber(item.quantity)}
매입가 {formatNumber(item.avg_price)}
현재가 {item.current_price != null ? formatNumber(item.current_price) : '조회 실패'}
수익률 {profitRate != null ? formatPercent(profitRate) : '-'}
평가손익 {profitAmt != null ? formatNumber(profitAmt) : '-'}
{/* action buttons */}
{isDeleting ? ( <> ) : ( )}
)}
); })}
); })} {/* ── Existing balance section ─────────────────────────── */} {balanceError ?

{balanceError}

: null}

잔고

보유 현황

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

{balanceLoading ? ( 조회 중 ) : null}
{[ { label: '총 평가', value: totalEval, }, { label: '예수금', value: deposit, }, ].map((item) => (
{item.label} {formatNumber(item.value)}
))}
{holdings.length ? (
{holdings.map((item, idx) => { const profitLoss = getProfitLoss(item); const profitLossNumeric = toNumeric(profitLoss); const profitClass = profitColorClass(profitLossNumeric); const profitRate = getProfitRate(item); const profitRateNumeric = toNumeric(profitRate); const profitRateClass = profitColorClass(profitRateNumeric); return (

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

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

보유 종목이 없습니다.

)}
{/* ── Manual order section ─────────────────────────────── */}

수동 주문

직접 매수/매도

종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.

{manualError ? (

{manualError}

) : null} {manualResult ? (

요청 결과

                                {typeof manualResult === 'string'
                                    ? manualResult
                                    : JSON.stringify(manualResult, null, 2)}
                            
) : null}
{/* KIS modal */} {kisModal ? (
setKisModal('')} />

주문 결과

{kisModal}
) : null}
); }; export default StockTrade;