/** * 매수 / 매도 주문 폼 (업비트 스타일) */ 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'; export type OrderKind = 'limit' | 'market' | 'stop_limit'; export interface TradeOrderPanelProps { side: 'buy' | 'sell'; market: string; /** 현재가 (KRW) — 종목 변경 시 초기값 */ tradePrice: number | null; availableKrw?: number; /** 매도 시 보유 수량 (모의투자) */ availableCoinQty?: number; /** 호가·차트에서 자동 입력 */ fillRequest?: TradeOrderFillRequest | null; /** 종목 검색 패널 앵커 (우측 매수·매도 영역) */ searchAnchorRef?: React.RefObject; /** 종목 변경 시 차트·호가 연동 */ 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 Math.round(n).toLocaleString('ko-KR'); } 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 digits = raw.replace(/\D/g, ''); if (!digits) return ''; return Number(digits).toLocaleString('ko-KR'); } /** 주문 수량: 숫자와 소수점(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 = ({ side, market, tradePrice, availableKrw = 0, availableCoinQty = 0, fillRequest, searchAnchorRef, onMarketSelect, showSymbolField = true, paperTradingEnabled = false, paperAutoTradeEnabled = false, onPaperOrderFilled, }) => { const isBuy = side === 'buy'; const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market; const code = coinCode(displayMarket); const koreanName = getKoreanName(displayMarket); const [orderKind, setOrderKind] = useState('limit'); const [priceStr, setPriceStr] = useState(''); const [qtyStr, setQtyStr] = useState(''); const [totalStr, setTotalStr] = useState('0'); const [pctMode, setPctMode] = useState(null); const [showSymbolSearch, setShowSymbolSearch] = useState(false); const [symbolQuery, setSymbolQuery] = useState(''); const symbolInputRef = useRef(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 step = priceStep(price > 0 ? price : (tradePrice ?? 0)); 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' || availableKrw <= 0) return; const budget = (availableKrw * pct) / 100; const p = orderKind === 'limit' && price > 0 ? price : (tradePrice ?? 0); 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, availableKrw, availableCoinQty, orderKind, price, tradePrice]); const bumpPrice = useCallback((delta: number) => { const base = price > 0 ? price : (tradePrice ?? 0); const next = Math.max(0, base + delta); setPriceStr(fmtKrw(next)); }, [price, tradePrice]); 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 trade = await placePaperOrder({ market: displayMarket, side: isBuy ? 'BUY' : 'SELL', orderKind: orderKind === 'market' ? 'market' : 'limit', price: execPrice, quantity: qty, source: 'MANUAL', }); if (!trade) { 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`, ); 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) => { setPriceStr(sanitizePriceInput(e.target.value)); }, []); const handleQtyChange = useCallback((e: React.ChangeEvent) => { setPctMode(null); setQtyStr(sanitizeQtyInput(e.target.value)); }, []); const priceLabel = isBuy ? '매수가격' : '매도가격'; const minOrder = 5000; const feeRate = 0.05; return (
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (

자동매매 OFF · 수동 주문만 모의 계좌에 체결됩니다.

)} {showSymbolField && (
openSymbolSearch('')} onChange={e => openSymbolSearch(e.target.value)} onKeyDown={e => { if (e.key === 'Escape') { setShowSymbolSearch(false); setSymbolQuery(''); } }} />
{showSymbolSearch && ReactDOM.createPortal( { setShowSymbolSearch(false); setSymbolQuery(''); }} anchorRect={searchAnchorRef?.current?.getBoundingClientRect() ?? null} />, document.body, )}
)}
{ORDER_KINDS.map(k => ( ))}
{isBuy ? '주문가능' : '보유수량'} {isBuy ? `${fmtKrw(availableKrw)} KRW` : `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
{PCT_BTNS.map(p => ( ))}
조건: 보통 ▾ · 최소 {fmtKrw(minOrder)} · 수수료 {feeRate}%
); }; export default TradeOrderPanel;