425 lines
18 KiB
JavaScript
425 lines
18 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { createTradeOrder, getTradeBalance } from '../../api';
|
|
import './Stock.css';
|
|
|
|
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.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 StockTrade = () => {
|
|
const [balance, setBalance] = useState(null);
|
|
const [balanceLoading, setBalanceLoading] = useState(false);
|
|
const [balanceError, setBalanceError] = useState('');
|
|
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);
|
|
setBalanceError('');
|
|
try {
|
|
const data = await getTradeBalance();
|
|
setBalance(data);
|
|
} catch (err) {
|
|
setBalanceError(err?.message ?? String(err));
|
|
} finally {
|
|
setBalanceLoading(false);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadBalance();
|
|
}, []);
|
|
|
|
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;
|
|
|
|
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>
|
|
</div>
|
|
{summary.note ? (
|
|
<p className="stock-status__note">{summary.note}</p>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
|
|
{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 =
|
|
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 (
|
|
<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>
|
|
|
|
<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>
|
|
{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;
|