Files
goldenChart/frontend/src/components/TradeOrderPanel.tsx
T
2026-06-06 00:56:45 +09:00

446 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 매수 / 매도 주문 폼 (업비트 스타일)
*/
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;