goldenChat base source add
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 매수 / 매도 주문 폼 (업비트 스타일)
|
||||
*/
|
||||
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<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 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<TradeOrderPanelProps> = ({
|
||||
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<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 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<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;
|
||||
|
||||
return (
|
||||
<div className={`top-panel top-panel--${side}`}>
|
||||
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
|
||||
<p className="top-paper-hint">자동매매 OFF · 수동 주문만 모의 계좌에 체결됩니다.</p>
|
||||
)}
|
||||
{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">
|
||||
<span className="top-label">{isBuy ? '주문가능' : '보유수량'}</span>
|
||||
<span className="top-balance">
|
||||
{isBuy
|
||||
? `${fmtKrw(availableKrw)} KRW`
|
||||
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="top-field">
|
||||
<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">
|
||||
<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 className="top-pct-row">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-field">
|
||||
<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>조건: 보통 ▾</span>
|
||||
<span className="top-meta-sep">·</span>
|
||||
<span>최소 {fmtKrw(minOrder)}</span>
|
||||
<span className="top-meta-sep">·</span>
|
||||
<span>수수료 {feeRate}%</span>
|
||||
</span>
|
||||
</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;
|
||||
Reference in New Issue
Block a user