Files
goldenChart/frontend/src/components/TradeAlertModal.tsx
T
2026-05-31 01:23:47 +09:00

295 lines
10 KiB
TypeScript

import React, { useState } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi';
import { formatSignalTime } from '../utils/tradeSignalDisplay';
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../utils/tradeSignalColors';
import { useTradeAlertTimeFormat } from '../utils/tradeAlertTimeFormat';
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 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,
}) => {
useTradeAlertTimeFormat();
const isBuy = signal.signalType === 'BUY';
const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR;
const accentGlow = isBuy ? 'rgba(239, 83, 80, 0.28)' : 'rgba(77, 171, 247, 0.28)';
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 app-popup-shell"
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">
발생 시각: {formatSignalTime(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;