446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
/**
|
||
* 매수 / 매도 주문 폼 (업비트 스타일)
|
||
*/
|
||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import ReactDOM from 'react-dom';
|
||
import { getKoreanName } from '../utils/marketNameCache';
|
||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||
import type { TradeOrderFillRequest } from '../types';
|
||
import { placePaperOrder } from '../utils/backendApi';
|
||
import { coerceFiniteNumber, formatUpbitKrwPrice, upbitKrwTickSize } from '../utils/safeFormat';
|
||
|
||
export type OrderKind = 'limit' | 'market' | 'stop_limit';
|
||
|
||
export interface TradeOrderPanelProps {
|
||
side: 'buy' | 'sell';
|
||
market: string;
|
||
/** 현재가 (KRW) — 종목 변경 시 초기값 */
|
||
tradePrice: number | null;
|
||
availableKrw?: number;
|
||
/** 종목별 추가 매수 가능 (한도 기준) */
|
||
availableSymbolBuyKrw?: number;
|
||
/** 매도 시 보유 수량 (모의투자) */
|
||
availableCoinQty?: number;
|
||
/** 호가·차트에서 자동 입력 */
|
||
fillRequest?: TradeOrderFillRequest | null;
|
||
/** 종목 검색 패널 앵커 (우측 매수·매도 영역) */
|
||
searchAnchorRef?: React.RefObject<HTMLElement | null>;
|
||
/** 종목 변경 시 차트·호가 연동 */
|
||
onMarketSelect?: (market: string) => void;
|
||
/** 매매 통합 탭에서 매도 블록은 종목 필드 숨김 (매수 블록에만 표시) */
|
||
showSymbolField?: boolean;
|
||
/** 모의투자 활성화 시 주문이 가상 계좌로 체결됨 */
|
||
paperTradingEnabled?: boolean;
|
||
/** 모의투자 자동매매 (OFF면 수동 주문만) */
|
||
paperAutoTradeEnabled?: boolean;
|
||
/** 모의 체결 후 잔고 갱신 */
|
||
onPaperOrderFilled?: () => void;
|
||
}
|
||
|
||
export function fmtKrw(n: number): string {
|
||
if (!Number.isFinite(n)) return '0';
|
||
return formatUpbitKrwPrice(n, '0');
|
||
}
|
||
|
||
function parseNum(s: string): number {
|
||
const v = Number(String(s).replace(/,/g, '').trim());
|
||
return Number.isFinite(v) ? v : 0;
|
||
}
|
||
|
||
/** KRW 가격: 숫자·소수점(필요 시)만 허용, 업비트 규칙으로 표시 */
|
||
function sanitizePriceInput(raw: string): string {
|
||
const cleaned = raw.replace(/[^\d.]/g, '');
|
||
if (!cleaned) return '';
|
||
const n = Number(cleaned);
|
||
if (!Number.isFinite(n)) return '';
|
||
return formatUpbitKrwPrice(n, cleaned);
|
||
}
|
||
|
||
/** 현재 가격에 맞는 호가 단위만큼 증감 */
|
||
export function stepPriceByTick(price: number, direction: 1 | -1): number {
|
||
const tick = upbitKrwTickSize(price);
|
||
return Math.round((price + direction * tick) / tick) * tick;
|
||
}
|
||
|
||
/** 주문 수량: 숫자와 소수점(1개)만 허용 */
|
||
function sanitizeQtyInput(raw: string): string {
|
||
let s = raw.replace(/[^\d.]/g, '');
|
||
const dot = s.indexOf('.');
|
||
if (dot !== -1) {
|
||
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '');
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function coinCode(market: string): string {
|
||
return market.replace(/^KRW-/, '');
|
||
}
|
||
|
||
function priceStep(price: number): number {
|
||
if (price >= 2_000_000) return 1000;
|
||
if (price >= 200_000) return 100;
|
||
if (price >= 20_000) return 10;
|
||
if (price >= 2_000) return 1;
|
||
if (price >= 200) return 0.1;
|
||
if (price >= 20) return 0.01;
|
||
return 0.0001;
|
||
}
|
||
|
||
const ORDER_KINDS: { id: OrderKind; label: string }[] = [
|
||
{ id: 'limit', label: '지정가' },
|
||
{ id: 'market', label: '시장가' },
|
||
{ id: 'stop_limit', label: '예약-지정가' },
|
||
];
|
||
|
||
const PCT_BTNS = [10, 25, 50, 100] as const;
|
||
|
||
const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
|
||
side,
|
||
market,
|
||
tradePrice,
|
||
availableKrw = 0,
|
||
availableSymbolBuyKrw,
|
||
availableCoinQty = 0,
|
||
fillRequest,
|
||
searchAnchorRef,
|
||
onMarketSelect,
|
||
showSymbolField = true,
|
||
paperTradingEnabled = false,
|
||
paperAutoTradeEnabled = false,
|
||
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);
|
||
|
||
const [orderKind, setOrderKind] = useState<OrderKind>('limit');
|
||
const [priceStr, setPriceStr] = useState('');
|
||
const [qtyStr, setQtyStr] = useState('');
|
||
const [totalStr, setTotalStr] = useState('0');
|
||
const [pctMode, setPctMode] = useState<number | null>(null);
|
||
const [showSymbolSearch, setShowSymbolSearch] = useState(false);
|
||
const [symbolQuery, setSymbolQuery] = useState('');
|
||
const symbolInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const openSymbolSearch = useCallback((q = '') => {
|
||
setSymbolQuery(q);
|
||
setShowSymbolSearch(true);
|
||
}, []);
|
||
|
||
const handleSymbolSelect = useCallback((m: string) => {
|
||
onMarketSelect?.(m);
|
||
setShowSymbolSearch(false);
|
||
setSymbolQuery('');
|
||
symbolInputRef.current?.blur();
|
||
}, [onMarketSelect]);
|
||
|
||
const symbolDisplay = koreanName && koreanName !== displayMarket
|
||
? `${koreanName} (${code})`
|
||
: (code || displayMarket);
|
||
|
||
useEffect(() => {
|
||
if (fillRequest && fillRequest.side === side) {
|
||
setPriceStr(fmtKrw(fillRequest.price));
|
||
setPctMode(null);
|
||
return;
|
||
}
|
||
if (tradePrice != null && tradePrice > 0) {
|
||
setPriceStr(fmtKrw(tradePrice));
|
||
}
|
||
}, [market, tradePrice, fillRequest?.seq, fillRequest?.side, fillRequest?.market, side]);
|
||
|
||
const price = parseNum(priceStr);
|
||
const qty = parseNum(qtyStr);
|
||
const refPrice = coerceFiniteNumber(tradePrice) ?? 0;
|
||
const step = priceStep(price > 0 ? price : refPrice);
|
||
|
||
useEffect(() => {
|
||
if (orderKind === 'market') {
|
||
setTotalStr(availableKrw > 0 ? fmtKrw(availableKrw) : '0');
|
||
return;
|
||
}
|
||
const total = price * qty;
|
||
setTotalStr(total > 0 ? fmtKrw(total) : '0');
|
||
}, [price, qty, orderKind, availableKrw]);
|
||
|
||
const applyPct = useCallback((pct: number) => {
|
||
setPctMode(pct);
|
||
if (isBuy) {
|
||
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;
|
||
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
|
||
}
|
||
return;
|
||
}
|
||
if (availableCoinQty <= 0) return;
|
||
const q = (availableCoinQty * pct) / 100;
|
||
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
|
||
}, [isBuy, buyBudgetKrw, availableCoinQty, orderKind, price, refPrice]);
|
||
|
||
const bumpPrice = useCallback((delta: number) => {
|
||
const base = price > 0 ? price : refPrice;
|
||
const next = Math.max(0, base + delta);
|
||
setPriceStr(fmtKrw(next));
|
||
}, [price, refPrice]);
|
||
|
||
const handleReset = useCallback(() => {
|
||
setOrderKind('limit');
|
||
setQtyStr('');
|
||
setTotalStr('0');
|
||
setPctMode(null);
|
||
if (tradePrice != null && tradePrice > 0) {
|
||
setPriceStr(fmtKrw(tradePrice));
|
||
} else {
|
||
setPriceStr('');
|
||
}
|
||
}, [tradePrice]);
|
||
|
||
const handleSubmit = useCallback(async () => {
|
||
const label = isBuy ? '매수' : '매도';
|
||
const execPrice = orderKind === 'market' && tradePrice != null && tradePrice > 0
|
||
? tradePrice
|
||
: price;
|
||
if (!paperTradingEnabled) {
|
||
window.alert('모의투자가 비활성화되어 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.');
|
||
return;
|
||
}
|
||
if (execPrice <= 0 || qty <= 0) {
|
||
window.alert('가격과 수량을 입력해 주세요.');
|
||
return;
|
||
}
|
||
try {
|
||
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 (!result?.trade && !result?.order) {
|
||
window.alert('모의 주문에 실패했습니다.');
|
||
return;
|
||
}
|
||
onPaperOrderFilled?.();
|
||
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) {
|
||
window.alert(e instanceof Error ? e.message : '모의 주문 처리 중 오류가 발생했습니다.');
|
||
}
|
||
}, [
|
||
isBuy, koreanName, code, orderKind, priceStr, qtyStr, totalStr, paperTradingEnabled,
|
||
displayMarket, orderKind, price, qty, tradePrice, onPaperOrderFilled,
|
||
]);
|
||
|
||
const handlePriceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setPriceStr(sanitizePriceInput(e.target.value));
|
||
}, []);
|
||
|
||
const handleQtyChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setPctMode(null);
|
||
setQtyStr(sanitizeQtyInput(e.target.value));
|
||
}, []);
|
||
|
||
const priceLabel = isBuy ? '매수가격' : '매도가격';
|
||
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--upbit top-panel--${side}`}>
|
||
<div className="top-panel-fields">
|
||
{showSymbolField && (
|
||
<div className="top-field">
|
||
<label className="top-label">종목</label>
|
||
<div className="top-symbol-field">
|
||
<input
|
||
ref={symbolInputRef}
|
||
type="text"
|
||
className="top-symbol-input"
|
||
value={showSymbolSearch ? symbolQuery : symbolDisplay}
|
||
placeholder="종목명/심볼 검색"
|
||
onFocus={() => openSymbolSearch('')}
|
||
onChange={e => openSymbolSearch(e.target.value)}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Escape') {
|
||
setShowSymbolSearch(false);
|
||
setSymbolQuery('');
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="top-symbol-search-btn"
|
||
title="종목 검색"
|
||
onClick={() => {
|
||
openSymbolSearch(symbolQuery);
|
||
symbolInputRef.current?.focus();
|
||
}}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
{showSymbolSearch && ReactDOM.createPortal(
|
||
<MarketSearchPanel
|
||
currentMarket={displayMarket}
|
||
initialQuery={symbolQuery}
|
||
onSelect={handleSymbolSelect}
|
||
onClose={() => { setShowSymbolSearch(false); setSymbolQuery(''); }}
|
||
anchorRect={searchAnchorRef?.current?.getBoundingClientRect() ?? null}
|
||
/>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="top-field">
|
||
<label className="top-label">
|
||
주문유형
|
||
<span className="top-label-help" title="주문 유형 안내">?</span>
|
||
</label>
|
||
<div className="top-order-kind">
|
||
{ORDER_KINDS.map(k => (
|
||
<button
|
||
key={k.id}
|
||
type="button"
|
||
className={`top-kind-btn${orderKind === k.id ? ' top-kind-btn--active' : ''}${k.id === 'stop_limit' ? ' top-kind-btn--wide' : ''}`}
|
||
onClick={() => setOrderKind(k.id)}
|
||
>
|
||
{k.label}
|
||
{k.id === 'stop_limit' && <span className="top-kind-arrow">▾</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<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">{balanceDisplay}</span>
|
||
</div>
|
||
|
||
<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>
|
||
<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"
|
||
className="top-input top-input--full top-input--readonly"
|
||
value={totalStr}
|
||
readOnly
|
||
/>
|
||
</div>
|
||
|
||
<div className="top-meta">
|
||
<span className="top-meta-line">
|
||
<span className="top-meta-sep">·</span>
|
||
<span>최소주문: {fmtKrw(minOrder)} KRW</span>
|
||
<span className="top-meta-sep">·</span>
|
||
<span>수수료(부가세 포함): {feeRate}%</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="top-actions">
|
||
<button type="button" className="top-reset" onClick={handleReset}>
|
||
<span className="top-reset-icon">↺</span>
|
||
초기화
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`top-submit top-submit--${side}`}
|
||
onClick={handleSubmit}
|
||
>
|
||
{isBuy ? '매수' : '매도'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TradeOrderPanel;
|