295 lines
10 KiB
TypeScript
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;
|