Files
goldenChart/frontend/src/components/TradeAlertModal.tsx
T
2026-06-06 02:53:39 +09:00

336 lines
12 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { placePaperOrder, placeLiveOrder, loadLiveSummary, loadStrategy, type StrategyDto } from '../utils/backendApi';
import { formatSignalTime } from '../utils/tradeSignalDisplay';
import { formatUpbitKrwPrice } from '../utils/safeFormat';
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../utils/tradeSignalColors';
import { useTradeAlertTimeFormat } from '../utils/tradeAlertTimeFormat';
import StrategyConditionView from './tradeNotification/StrategyConditionView';
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 ? formatUpbitKrwPrice(n, '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 [strategy, setStrategy] = useState<StrategyDto | null>(null);
const [stratLoading, setStratLoading] = useState(false);
useEffect(() => {
if (!signal.strategyId) return;
setStratLoading(true);
loadStrategy(signal.strategyId)
.then(s => setStrategy(s))
.catch(() => setStrategy(null))
.finally(() => setStratLoading(false));
}, [signal.strategyId]);
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-modal-body">
<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>
{/* ── 전략 조건 섹션 ────────────────────────────── */}
{signal.strategyId && (
<div className="tam-condition-section">
<div className="tam-condition-section-header">
<span className="tam-condition-section-title">전략 조건</span>
{stratLoading && <span className="tam-condition-section-loading">로딩 중…</span>}
</div>
<div className="tam-condition-panel">
{stratLoading && (
<div className="tam-condition-loading">전략 조건 로딩 중…</div>
)}
{!stratLoading && !strategy && (
<div className="tam-condition-empty">전략 조건을 불러올 없습니다.</div>
)}
{!stratLoading && strategy && (
<StrategyConditionView strategy={strategy} signalType={signal.signalType} />
)}
</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>{/* /.tam-modal-body */}
</div>
</div>
);
};
export default TradeAlertModal;