goldenChat base source add
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi';
|
||||
import { formatUnixDateTime } from '../utils/timezone';
|
||||
|
||||
export interface TradeSignalInfo {
|
||||
market: string;
|
||||
signalType: 'BUY' | 'SELL';
|
||||
price: number;
|
||||
candleTime: number;
|
||||
strategyName?: string | null;
|
||||
strategyId?: number | null;
|
||||
executionType?: string;
|
||||
candleType?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
signal: TradeSignalInfo;
|
||||
onClose: () => void;
|
||||
centered?: boolean;
|
||||
tradingMode?: string;
|
||||
hasUpbitKeys?: boolean;
|
||||
paperTradingEnabled?: boolean;
|
||||
liveAutoTradeBudgetPct?: number;
|
||||
paperAutoTradeBudgetPct?: number;
|
||||
onOrderDone?: () => void;
|
||||
}
|
||||
|
||||
const fmt = (n: number): string =>
|
||||
n > 0 ? n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : '0';
|
||||
|
||||
function fmtTime(unix: number): string {
|
||||
return formatUnixDateTime(unix);
|
||||
}
|
||||
|
||||
function execLabel(type?: string, candleType?: string): string {
|
||||
const base = type === 'REALTIME_TICK' ? '실시간 틱' : '봉 마감';
|
||||
return candleType ? `${base}(${candleType})` : base;
|
||||
}
|
||||
|
||||
const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="9" width="5" height="12" rx="1" fill={color} opacity="0.9"/>
|
||||
<line x1="8.5" y1="5" x2="8.5" y2="9" stroke={color} strokeWidth="1.5"/>
|
||||
<line x1="8.5" y1="21" x2="8.5" y2="25" stroke={color} strokeWidth="1.5"/>
|
||||
<rect x="17" y="7" width="5" height="9" rx="1" fill="var(--down,#4dabf7)" opacity="0.8"/>
|
||||
<line x1="19.5" y1="4" x2="19.5" y2="7" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
|
||||
<line x1="19.5" y1="16" x2="19.5" y2="20" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TradeAlertModal: React.FC<Props> = ({
|
||||
signal,
|
||||
onClose,
|
||||
centered = false,
|
||||
tradingMode = 'PAPER',
|
||||
hasUpbitKeys = false,
|
||||
paperTradingEnabled = true,
|
||||
liveAutoTradeBudgetPct = 95,
|
||||
paperAutoTradeBudgetPct = 95,
|
||||
onOrderDone,
|
||||
}) => {
|
||||
const isBuy = signal.signalType === 'BUY';
|
||||
const accentColor = isBuy ? '#3f7ef5' : '#ef5350';
|
||||
const accentGlow = isBuy ? 'rgba(63,126,245,0.22)' : 'rgba(239,83,80,0.22)';
|
||||
const signalLabel = isBuy ? 'BUY' : 'SELL';
|
||||
const signalKo = isBuy ? '매수 알림' : '매도 알림';
|
||||
|
||||
const coinSymbol = signal.market.replace(/^[A-Z]+-/, '');
|
||||
const fiatSymbol = signal.market.split('-')[0] ?? 'KRW';
|
||||
|
||||
const useLive = (tradingMode === 'LIVE' || tradingMode === 'BOTH') && hasUpbitKeys;
|
||||
const usePaper = (tradingMode === 'PAPER' || tradingMode === 'BOTH') && paperTradingEnabled;
|
||||
|
||||
const [limitPrice, setLimitPrice] = useState(fmt(signal.price));
|
||||
const [memo, setMemo] = useState('');
|
||||
const [activePct, setActivePct] = useState<string | null>(null);
|
||||
const [ordering, setOrdering] = useState(false);
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({
|
||||
centerOnMount: centered,
|
||||
initialPosition: { x: 40, y: 40 },
|
||||
});
|
||||
|
||||
const handlePct = (pct: string) => {
|
||||
setActivePct(pct);
|
||||
};
|
||||
|
||||
const resolveBudgetPct = (): number => {
|
||||
if (activePct === '25%') return 25;
|
||||
if (activePct === '50%') return 50;
|
||||
if (activePct === '100%') return 100;
|
||||
return paperAutoTradeBudgetPct;
|
||||
};
|
||||
|
||||
const handleOrder = async (type: 'limit' | 'market') => {
|
||||
if (ordering) return;
|
||||
setOrdering(true);
|
||||
try {
|
||||
const side = signal.signalType;
|
||||
const budgetPct = resolveBudgetPct();
|
||||
const orderPrice = type === 'limit'
|
||||
? Number(limitPrice.replace(/,/g, ''))
|
||||
: signal.price;
|
||||
let executed = false;
|
||||
|
||||
if (usePaper) {
|
||||
await placePaperOrder({
|
||||
market: signal.market,
|
||||
side,
|
||||
orderKind: type === 'market' ? 'market' : 'limit',
|
||||
price: orderPrice,
|
||||
quantity: 0,
|
||||
budgetPct,
|
||||
source: 'MANUAL',
|
||||
strategyId: signal.strategyId ?? undefined,
|
||||
});
|
||||
executed = true;
|
||||
}
|
||||
|
||||
if (useLive && type === 'market') {
|
||||
const livePct = activePct === '25%' ? 25 : activePct === '50%' ? 50 : activePct === '100%' ? 100 : liveAutoTradeBudgetPct;
|
||||
const summary = isBuy ? await loadLiveSummary() : null;
|
||||
const krwAmount = isBuy && summary
|
||||
? summary.krwBalance * livePct / 100
|
||||
: undefined;
|
||||
await placeLiveOrder({
|
||||
market: signal.market,
|
||||
side,
|
||||
orderKind: 'market',
|
||||
krwAmount,
|
||||
strategyId: signal.strategyId ?? undefined,
|
||||
});
|
||||
executed = true;
|
||||
}
|
||||
|
||||
if (!executed) {
|
||||
window.alert('주문 가능한 채널이 없습니다. 설정에서 모의투자 또는 실거래 API를 확인하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
onOrderDone?.();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
window.alert((e as Error).message);
|
||||
} finally {
|
||||
setOrdering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tam-overlay">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="tam-modal"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 10000,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
'--tam-accent': accentColor,
|
||||
'--tam-accent-glow': accentGlow,
|
||||
} as React.CSSProperties}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header tam-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<div className="tam-header-left">
|
||||
<span className="tam-badge" style={{ background: accentColor }}>
|
||||
{signalLabel}
|
||||
</span>
|
||||
<span className="tam-header-title">
|
||||
ALERT
|
||||
<span className="tam-header-ko"> ({signalKo})</span>
|
||||
</span>
|
||||
</div>
|
||||
<button className="tam-close" onClick={onClose} title="닫기">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="tam-price-section">
|
||||
<div className="tam-price-row">
|
||||
<span className="tam-market-name">{signal.market}</span>
|
||||
<span className="tam-price-arrow" style={{ color: accentColor }}>
|
||||
{isBuy ? '↑' : '↓'}
|
||||
</span>
|
||||
<span className="tam-price-value" style={{ color: accentColor }}>
|
||||
₩{fmt(signal.price)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tam-price-time">
|
||||
발생 시각: {fmtTime(signal.candleTime)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tam-strategy-card">
|
||||
<div className="tam-strategy-icon">
|
||||
<CandleIcon color={accentColor} />
|
||||
</div>
|
||||
<div className="tam-strategy-info">
|
||||
<div className="tam-strategy-name">
|
||||
<span className="tam-strategy-label">전략명</span>
|
||||
<span className="tam-strategy-val">
|
||||
{signal.strategyName ?? '(전략 미지정)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tam-strategy-exec">
|
||||
<span className="tam-strategy-label">실행 방식</span>
|
||||
<span className="tam-strategy-val tam-strategy-exec-val">
|
||||
{execLabel(signal.executionType, signal.candleType)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tam-order-section">
|
||||
<div className="tam-field-row">
|
||||
<span className="tam-field-label">자산 비율</span>
|
||||
<div className="tam-pct-group">
|
||||
{['25%', '50%', '100%'].map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={`tam-pct-btn2 ${activePct === p ? 'tam-pct-btn2--active' : ''}`}
|
||||
style={activePct === p ? { borderColor: accentColor, color: accentColor } : undefined}
|
||||
onClick={() => handlePct(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tam-field-row">
|
||||
<span className="tam-field-label">지정가</span>
|
||||
<div className="tam-price-input-wrap">
|
||||
<input
|
||||
className="tam-price-input"
|
||||
value={limitPrice}
|
||||
onChange={e => setLimitPrice(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tam-market-btn"
|
||||
disabled={ordering}
|
||||
onClick={() => handleOrder('market')}
|
||||
>
|
||||
시장가 주문
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tam-field-row tam-field-row--memo">
|
||||
<span className="tam-field-label">메모</span>
|
||||
<input
|
||||
className="tam-memo-input"
|
||||
value={memo}
|
||||
onChange={e => setMemo(e.target.value)}
|
||||
placeholder="메모 입력 (선택)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tam-cta-btn"
|
||||
style={{ background: accentColor }}
|
||||
disabled={ordering}
|
||||
onClick={() => handleOrder('limit')}
|
||||
>
|
||||
{ordering ? '주문 중…' : `${fiatSymbol} ${coinSymbol} ${isBuy ? '매수' : '매도'} (${usePaper ? '모의' : '실거래'})`}
|
||||
</button>
|
||||
|
||||
<p className="tam-disclaimer">
|
||||
{useLive && usePaper && '※ 모의·실거래 병행: 시장가는 모의·실거래 모두 체결, 지정가는 모의 계좌 체결'}
|
||||
{useLive && !usePaper && '※ 업비트 실거래 시장가 주문'}
|
||||
{!useLive && usePaper && '※ 모의투자 계좌 체결 (자동매매와 동일 내역 반영)'}
|
||||
{!useLive && !usePaper && '※ 설정에서 모의투자 또는 API 키를 활성화하세요'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeAlertModal;
|
||||
Reference in New Issue
Block a user