goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+415
View File
@@ -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;