모의투자 관리 기능 추가

This commit is contained in:
Macbook
2026-05-31 03:47:07 +09:00
parent 9d7dddfa57
commit 16d0e2c226
78 changed files with 4688 additions and 972 deletions
+88 -70
View File
@@ -17,6 +17,8 @@ export interface TradeOrderPanelProps {
/** 현재가 (KRW) — 종목 변경 시 초기값 */
tradePrice: number | null;
availableKrw?: number;
/** 종목별 추가 매수 가능 (한도 기준) */
availableSymbolBuyKrw?: number;
/** 매도 시 보유 수량 (모의투자) */
availableCoinQty?: number;
/** 호가·차트에서 자동 입력 */
@@ -89,6 +91,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
market,
tradePrice,
availableKrw = 0,
availableSymbolBuyKrw,
availableCoinQty = 0,
fillRequest,
searchAnchorRef,
@@ -99,6 +102,9 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
onPaperOrderFilled,
}) => {
const isBuy = side === 'buy';
const buyBudgetKrw = isBuy && availableSymbolBuyKrw != null
? Math.min(availableKrw, availableSymbolBuyKrw)
: availableKrw;
const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market;
const code = coinCode(displayMarket);
const koreanName = getKoreanName(displayMarket);
@@ -156,8 +162,8 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const applyPct = useCallback((pct: number) => {
setPctMode(pct);
if (isBuy) {
if (orderKind === 'market' || availableKrw <= 0) return;
const budget = (availableKrw * pct) / 100;
if (orderKind === 'market' || buyBudgetKrw <= 0) return;
const budget = (buyBudgetKrw * pct) / 100;
const p = orderKind === 'limit' && price > 0 ? price : refPrice;
if (p > 0) {
const q = budget / p;
@@ -168,7 +174,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
if (availableCoinQty <= 0) return;
const q = (availableCoinQty * pct) / 100;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
}, [isBuy, availableKrw, availableCoinQty, orderKind, price, refPrice]);
}, [isBuy, buyBudgetKrw, availableCoinQty, orderKind, price, refPrice]);
const bumpPrice = useCallback((delta: number) => {
const base = price > 0 ? price : refPrice;
@@ -202,26 +208,36 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
return;
}
try {
const trade = await placePaperOrder({
const result = await placePaperOrder({
market: displayMarket,
side: isBuy ? 'BUY' : 'SELL',
orderKind: orderKind === 'market' ? 'market' : 'limit',
orderType: orderKind === 'market' ? 'MARKET' : 'LIMIT',
price: execPrice,
quantity: qty,
source: 'MANUAL',
});
if (!trade) {
if (!result?.trade && !result?.order) {
window.alert('모의 주문에 실패했습니다.');
return;
}
onPaperOrderFilled?.();
window.alert(
`[모의투자] ${koreanName || code} ${label} 체결\n` +
`가격: ${fmtKrw(trade.price)} KRW\n` +
`수량: ${trade.quantity} ${code}\n` +
`수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
);
if (result.order) {
window.alert(
`[모의투자] ${koreanName || code} ${label} 미체결 등록\n` +
`지정가: ${fmtKrw(result.order.limitPrice ?? execPrice)} KRW\n` +
`: ${result.order.quantity} ${code}`,
);
} else if (result.trade) {
const trade = result.trade;
window.alert(
`[모의투자] ${koreanName || code} ${label} 체결\n` +
`가격: ${fmtKrw(trade.price)} KRW\n` +
`수량: ${trade.quantity} ${code}\n` +
`수수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
);
}
setQtyStr('');
setPctMode(null);
} catch (e) {
@@ -245,8 +261,12 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const minOrder = 5000;
const feeRate = 0.05;
const balanceDisplay = isBuy
? `${buyBudgetKrw > 0 ? fmtKrw(buyBudgetKrw) : '-'} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`;
return (
<div className={`top-panel top-panel--${side}`}>
<div className={`top-panel top-panel--upbit top-panel--${side}`}>
<div className="top-panel-fields">
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
<p className="top-paper-hint"> OFF · .</p>
@@ -317,71 +337,70 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
</div>
</div>
<div className="top-row top-row--balance">
<div
className="top-row top-row--balance"
title={isBuy && availableSymbolBuyKrw != null
? `종목별 한도 ${fmtKrw(availableSymbolBuyKrw)} KRW`
: undefined}
>
<span className="top-label">{isBuy ? '주문가능' : '보유수량'}</span>
<span className="top-balance">
{isBuy
? `${fmtKrw(availableKrw)} KRW`
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
</span>
<span className="top-balance">{balanceDisplay}</span>
</div>
<div className="top-price-qty-block">
<div className="top-field top-field--price">
<label className="top-label">{priceLabel} (KRW)</label>
<div className="top-input-group">
<input
type="text"
className="top-input"
value={priceStr}
disabled={orderKind === 'market'}
onChange={handlePriceChange}
inputMode="numeric"
pattern="[0-9,]*"
autoComplete="off"
/>
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}></button>
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
</div>
</div>
<div className="top-field top-field--qty">
<label className="top-label"> ({code})</label>
<div className="top-field top-field--price">
<label className="top-label">{priceLabel} (KRW)</label>
<div className="top-input-group">
<input
type="text"
className="top-input top-input--full"
value={qtyStr}
className="top-input"
value={priceStr}
disabled={orderKind === 'market'}
onChange={handleQtyChange}
inputMode="decimal"
pattern="[0-9.]*"
onChange={handlePriceChange}
inputMode="numeric"
pattern="[0-9,]*"
autoComplete="off"
placeholder="0"
/>
</div>
<div className="top-pct-row top-pct-row--block">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
onClick={() => applyPct(p)}
>
{p === 100 ? '최대' : `${p}%`}
</button>
))}
<button
type="button"
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
onClick={() => setPctMode(-1)}
>
</button>
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}></button>
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
</div>
</div>
<div className="top-field">
<div className="top-field top-field--qty">
<label className="top-label"> ({code})</label>
<input
type="text"
className="top-input top-input--full"
value={qtyStr}
disabled={orderKind === 'market'}
onChange={handleQtyChange}
inputMode="decimal"
pattern="[0-9.]*"
autoComplete="off"
placeholder="0"
/>
</div>
<div className="top-pct-row top-pct-row--under-qty">
{PCT_BTNS.map(p => (
<button
key={p}
type="button"
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
onClick={() => applyPct(p)}
>
{`${p}%`}
</button>
))}
<button
type="button"
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
onClick={() => setPctMode(-1)}
>
</button>
</div>
<div className="top-field top-field--total">
<label className="top-label"> (KRW)</label>
<input
type="text"
@@ -393,11 +412,10 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
<div className="top-meta">
<span className="top-meta-line">
<span>조건: 보통 </span>
<span className="top-meta-sep">·</span>
<span> {fmtKrw(minOrder)}</span>
<span>: {fmtKrw(minOrder)} KRW</span>
<span className="top-meta-sep">·</span>
<span> {feeRate}%</span>
<span>( ): {feeRate}%</span>
</span>
</div>
</div>