Files
web-page/src/pages/stock/StockTrade.jsx

993 lines
46 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="stock">
<header className="stock-header">
<div>
<p className="stock-kicker">거래 데스크</p>
<h1>주식 거래</h1>
<p className="stock-sub">
연결된 계좌 잔고를 확인하고 필요한 주문을 요청하세요.
</p>
<div className="stock-actions">
<Link className="button ghost" to="/stock">
주식 랩으로 돌아가기
</Link>
</div>
</div>
<div className="stock-card">
<p className="stock-card__title">계좌 요약</p>
<div className="stock-status">
<div>
<span> 평가금액</span>
<strong>{formatNumber(totalEval)}</strong>
</div>
<div>
<span>예수금</span>
<strong>{formatNumber(deposit)}</strong>
</div>
<div>
<span>보유 종목</span>
<strong>{holdings.length}</strong>
</div>
{portfolioHoldings.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--line)', paddingTop: 8 }}>
<span>포트폴리오 종목</span>
<strong>{portfolioHoldings.length}</strong>
</div>
<div>
<span>포트폴리오 평가</span>
<strong>{formatNumber(portfolioSummary.total_eval)}</strong>
</div>
<div>
<span>포트폴리오 손익</span>
<strong
className={`stock-profit ${profitColorClass(
toNumeric(portfolioSummary.total_profit)
)}`}
>
{formatNumber(portfolioSummary.total_profit)}
{portfolioSummary.total_profit_rate != null && (
<small style={{ marginLeft: 4, fontSize: 11 }}>
({formatPercent(portfolioSummary.total_profit_rate)})
</small>
)}
</strong>
</div>
</>
)}
</div>
{summary.note ? (
<p className="stock-status__note">{summary.note}</p>
) : null}
</div>
</header>
{/* ── Portfolio sections (broker별) ─────────────────────── */}
{portfolioError ? (
<p className="stock-error">{portfolioError}</p>
) : null}
{/* 총 포트폴리오 요약 + 종목 추가 */}
<section className="stock-panel stock-panel--wide pf-section">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">포트폴리오</p>
<h3>수동 입력 종목 관리</h3>
<p className="stock-panel__sub">
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3 캐시)
</p>
</div>
<div className="stock-panel__actions">
{portfolioLoading ? (
<Loading type="spinner" message="" />
) : null}
<button
className="button ghost small"
onClick={loadPortfolio}
disabled={portfolioLoading}
>
새로고침
</button>
<button
className="button primary small"
onClick={() => setAddFormOpen((v) => !v)}
>
{addFormOpen ? '취소' : '+ 종목 추가'}
</button>
</div>
</div>
{/* Add form */}
{addFormOpen && (
<form className="pf-add-form" onSubmit={handleAddSubmit}>
<label>
증권사
<input
type="text"
value={addForm.broker}
onChange={(e) =>
setAddForm((p) => ({ ...p, broker: e.target.value }))
}
placeholder="KB증권"
required
/>
</label>
<label>
종목코드
<input
type="text"
value={addForm.ticker}
onChange={(e) =>
setAddForm((p) => ({ ...p, ticker: e.target.value }))
}
placeholder="005930"
required
maxLength={6}
/>
</label>
<label>
종목명
<input
type="text"
value={addForm.name}
onChange={(e) =>
setAddForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="삼성전자"
required
/>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={addForm.quantity}
onChange={(e) =>
setAddForm((p) => ({ ...p, quantity: e.target.value }))
}
required
/>
</label>
<label>
평균 매입가 ()
<input
type="number"
min={0}
step={1}
value={addForm.avg_price}
onChange={(e) =>
setAddForm((p) => ({ ...p, avg_price: e.target.value }))
}
required
/>
</label>
<button
className="button primary"
type="submit"
disabled={addLoading}
>
{addLoading ? '등록 중...' : '종목 등록'}
</button>
{addError && <p className="stock-error">{addError}</p>}
</form>
)}
{/* Portfolio total summary */}
{portfolioHoldings.length > 0 && (
<div className="pf-total-summary">
{[
{ 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) => (
<div key={s.label} className="pf-total-summary__card">
<span>{s.label}</span>
<strong
className={
s.isProfit || s.isRate
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
: ''
}
>
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
</strong>
</div>
))}
</div>
)}
</section>
{/* Each broker gets a stacked card */}
{brokerGroups.map(([broker, items]) => {
const bSummary = getBrokerSummary(items);
const color = brokerColors[broker];
return (
<section
key={broker}
className="stock-panel stock-panel--wide pf-broker-section"
style={{
borderColor: color?.border,
background: color?.bg,
}}
>
<div className="stock-panel__head">
<div>
<p
className="stock-panel__eyebrow"
style={{ color: color?.border }}
>
{broker}
</p>
<h3>{broker} 보유 현황</h3>
<p className="stock-panel__sub">
{items.length}종목 · 평가{' '}
{formatNumber(bSummary.totalEval)} · 손익{' '}
<span
className={`stock-profit ${profitColorClass(
bSummary.totalProfit
)}`}
>
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
</span>
</p>
</div>
</div>
<div className="stock-holdings">
{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 (
<div
key={item.id}
className="stock-holdings__item pf-item"
>
{isEditing ? (
/* Edit mode */
<div className="pf-edit-row">
<div className="pf-edit-fields">
<label>
수량
<input
type="number"
min={1}
value={editForm.quantity ?? ''}
onChange={(e) =>
setEditForm((p) => ({
...p,
quantity: Number(e.target.value),
}))
}
/>
</label>
<label>
평균매입가
<input
type="number"
min={0}
value={editForm.avg_price ?? ''}
onChange={(e) =>
setEditForm((p) => ({
...p,
avg_price: Number(e.target.value),
}))
}
/>
</label>
</div>
<div className="pf-edit-actions">
<button
className="button primary small"
onClick={() => handleEditSave(item.id)}
disabled={editLoading}
>
{editLoading ? '저장 중...' : '저장'}
</button>
<button
className="button ghost small"
onClick={() => setEditingId(null)}
>
취소
</button>
</div>
</div>
) : (
/* Normal display */
<>
<div>
<p className="stock-holdings__name">
{item.name ?? item.ticker ?? 'N/A'}
</p>
<span className="stock-holdings__code">
{item.ticker ?? ''}
</span>
</div>
<div className="stock-holdings__metric">
<span>수량</span>
<strong>{formatNumber(item.quantity)}</strong>
</div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>{formatNumber(item.avg_price)}</strong>
</div>
<div className="stock-holdings__metric">
<span>현재가</span>
<strong
className={item.current_price == null ? 'pf-null-price' : ''}
>
{item.current_price != null
? formatNumber(item.current_price)
: '조회 실패'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>수익률</span>
<strong
className={`stock-profit ${profitColorClass(profitRateN)}`}
>
{profitRate != null
? formatPercent(profitRate)
: '-'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가손익</span>
<strong
className={`stock-profit ${profitColorClass(profitAmtN)}`}
>
{profitAmt != null
? formatNumber(profitAmt)
: '-'}
</strong>
</div>
{/* action buttons */}
<div className="pf-item-actions">
<button
className="button ghost small"
onClick={() => handleEditStart(item)}
title="수정"
>
</button>
{isDeleting ? (
<>
<button
className="button ghost small pf-btn-danger"
onClick={() => handleDelete(item.id)}
>
확인
</button>
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(null)
}
>
취소
</button>
</>
) : (
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(item.id)
}
title="삭제"
>
🗑
</button>
)}
</div>
</>
)}
</div>
);
})}
</div>
</section>
);
})}
{/* ── Existing balance section ─────────────────────────── */}
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">잔고</p>
<h3>보유 현황</h3>
<p className="stock-panel__sub">
연결 계좌의 실시간 잔고와 보유 종목을 확인합니다.
</p>
</div>
<div className="stock-panel__actions">
{balanceLoading ? (
<span className="stock-chip">조회 </span>
) : null}
<button
className="button ghost small"
onClick={loadBalance}
disabled={balanceLoading}
>
새로고침
</button>
</div>
</div>
<div className="stock-balance">
<div className="stock-balance__summary">
{[
{
label: '총 평가',
value: totalEval,
},
{
label: '예수금',
value: deposit,
},
].map((item) => (
<div
key={item.label}
className="stock-balance__card"
>
<span>{item.label}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
))}
</div>
{holdings.length ? (
<div className="stock-holdings">
{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 (
<div
key={item.code ?? `${item.name}-${idx}`}
className="stock-holdings__item"
>
<div>
<p className="stock-holdings__name">
{item.name ?? item.code ?? 'N/A'}
</p>
<span className="stock-holdings__code">
{item.code ?? ''}
</span>
</div>
<div className="stock-holdings__metric">
<span>수량</span>
<strong>
{formatNumber(getQty(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>
{formatNumber(getBuyPrice(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>현재가</span>
<strong>
{formatNumber(
getCurrentPrice(item)
)}
</strong>
</div>
<div className="stock-holdings__metric">
<span>수익률</span>
<strong
className={`stock-profit ${profitRateClass}`}
>
{formatPercent(profitRate)}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가손익</span>
<strong
className={`stock-profit ${profitClass}`}
>
{formatNumber(profitLoss)}
</strong>
</div>
</div>
);
})}
</div>
) : (
<p className="stock-empty">보유 종목이 없습니다.</p>
)}
</div>
</section>
{/* ── Manual order section ─────────────────────────────── */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">수동 주문</p>
<h3>직접 매수/매도</h3>
<p className="stock-panel__sub">
종목명 또는 종목코드를 입력하고 매수/매도 주문을
요청합니다.
</p>
</div>
</div>
<form className="stock-order" onSubmit={submitManualOrder}>
<label>
종목명/코드
<input
type="text"
value={manualForm.code}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
code: event.target.value,
}))
}
placeholder="005930 또는 삼성전자"
required
/>
</label>
<label>
매수/매도
<select
value={manualForm.type}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
type: event.target.value,
}))
}
>
<option value="buy">매수</option>
<option value="sell">매도</option>
</select>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={manualForm.qty}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
qty: Number(event.target.value),
}))
}
required
/>
</label>
<label>
금액()
<input
type="number"
min={0}
step={1}
value={manualForm.price}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
price: Number(event.target.value),
}))
}
/>
</label>
<button
className="button primary"
type="submit"
disabled={manualLoading}
>
{manualLoading ? '요청 중...' : '주문 요청'}
</button>
{manualError ? (
<p className="stock-error">{manualError}</p>
) : null}
{manualResult ? (
<div className="stock-result">
<p className="stock-result__title">요청 결과</p>
<pre>
{typeof manualResult === 'string'
? manualResult
: JSON.stringify(manualResult, null, 2)}
</pre>
</div>
) : null}
</form>
</section>
{/* KIS modal */}
{kisModal ? (
<div className="stock-modal" role="dialog" aria-modal="true">
<div
className="stock-modal__backdrop"
onClick={() => setKisModal('')}
/>
<div className="stock-modal__card">
<div className="stock-modal__head">
<h4>주문 결과</h4>
<button
type="button"
className="button ghost small"
onClick={() => setKisModal('')}
>
닫기
</button>
</div>
<pre>{kisModal}</pre>
</div>
</div>
) : null}
</div>
);
};
export default StockTrade;