/** * 매매 시그널 알림 목록 화면 — 좌: 알림 목록, 우: 매매·호가 (가상매매 우측 패널과 동일) */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Theme, TradeOrderFillRequest } from '../types'; import type { TickerData } from '../hooks/useMarketTicker'; import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext'; import { DEFAULT_BACKTEST_SETTINGS, loadPaperSummary, loadStrategy, runBacktest, type PaperSummaryDto, } from '../utils/backendApi'; import { loadAnalysisCandles } from '../utils/analysisChartData'; import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; import { candleTypeToTimeframe } from '../utils/strategyToChartIndicators'; import { normalizeEpochSec } from '../utils/backtestUiUtils'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; import { coerceFiniteNumber } from '../utils/safeFormat'; import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelTabs, TradeRightPanelBody, } from './trade'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import { useAppSettings } from '../hooks/useAppSettings'; import { readStoredBool, storeBool } from './strategyEditor/usePanelResize'; import '../styles/strategyEditorTheme.css'; import '../styles/virtualTradingDashboard.css'; import '../styles/builderPageShell.css'; import '../styles/tradeNotificationList.css'; import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow'; import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker'; import FullscreenChartModal from './tradeNotification/FullscreenChartModal'; import { loadTradeNotificationGridPreset, loadTradeNotificationListLayout, loadTradeNotificationCandleFilter, loadTradeNotificationListSort, saveTradeNotificationCandleFilter, saveTradeNotificationGridPreset, saveTradeNotificationListLayout, saveTradeNotificationListSort, TRADE_NOTIFICATION_CANDLE_FILTER_OPTIONS, TRADE_NOTIFICATION_READ_FILTER_OPTIONS, TRADE_NOTIFICATION_SORT_OPTIONS, type TradeNotificationCandleFilter, type TradeNotificationListLayout, type TradeNotificationListSort, type TradeNotificationReadFilter, } from '../utils/tradeNotificationListLayout'; import { normalizeStartCandleType } from '../utils/strategyStartNodes'; import { getTradeNotificationGridPreset, type TradeNotificationGridPresetId, } from '../utils/tradeNotificationGridPresets'; import { sortTradeNotifications } from '../utils/tradeListSort'; type RightTab = 'trade' | 'orderbook'; const TNL_RIGHT_OPEN_KEY = 'tnl-right-open'; const REPORT_BAR_COUNT = 300; const REPORT_INDICATOR_TYPES = [ 'RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological', ] as const; const IcTrash = () => ( ); const IcRefresh = () => ( ); interface Props { theme: Theme; onGoToChart: (market: string) => void; tickers?: Map; defaultMarket?: string; paperTradingEnabled?: boolean; paperAutoTradeEnabled?: boolean; onPaperOrderFilled?: () => void; } function fillBothSides( market: string, price: number, setFillBuy: React.Dispatch>, setFillSell: React.Dispatch>, ) { const seq = Date.now(); setFillBuy({ market, price, side: 'buy', seq }); setFillSell({ market, price, side: 'sell', seq: seq + 1 }); } export const TradeNotificationListPage: React.FC = ({ theme, onGoToChart, tickers, defaultMarket = 'KRW-BTC', paperTradingEnabled = true, paperAutoTradeEnabled = false, onPaperOrderFilled, }) => { const { allNotifications, unreadCount, markAsRead, markAllAsRead, deleteNotification, deleteNotifications, deleteAllNotifications, openDetail, refreshHistory, } = useTradeNotification(); const [query, setQuery] = useState(''); const normalizedQuery = useMemo(() => query.trim().toLowerCase(), [query]); const [filter, setFilter] = useState('all'); const [listSort, setListSort] = useState( () => loadTradeNotificationListSort(), ); const [candleFilter, setCandleFilter] = useState( () => loadTradeNotificationCandleFilter(), ); const [listLayout, setListLayout] = useState( () => loadTradeNotificationListLayout(), ); const [gridPreset, setGridPreset] = useState( () => loadTradeNotificationGridPreset(), ); const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [selectedId, setSelectedId] = useState(null); const [reportOpen, setReportOpen] = useState(false); const [reportModel, setReportModel] = useState(null); const [reportLoadingId, setReportLoadingId] = useState(null); const reportGenRef = useRef(0); const [rightTab, setRightTab] = useState('trade'); const [fillBuy, setFillBuy] = useState(null); const [fillSell, setFillSell] = useState(null); const [summary, setSummary] = useState(null); const orderAnchorRef = useRef(null); const [refreshing, setRefreshing] = useState(false); const [rightOpen, setRightOpen] = useState(() => readStoredBool(TNL_RIGHT_OPEN_KEY, true)); const [fullscreenItem, setFullscreenItem] = useState(null); const { settings: appSettings } = useAppSettings(); const { getParams } = useIndicatorSettings(); const chartLiveReceiveHighlight = appSettings.chartLiveReceiveHighlight ?? true; const refreshPaperData = useCallback(async () => { try { const s = await loadPaperSummary(); setSummary(s); } catch { setSummary(null); } onPaperOrderFilled?.(); }, [onPaperOrderFilled]); useEffect(() => { void refreshPaperData(); }, [refreshPaperData]); const tradePrice = useMemo( () => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice), [tickers, selectedMarket], ); const posQty = useMemo(() => { const p = summary?.positions?.find(x => x.symbol === selectedMarket); return coerceFiniteNumber(p?.quantity) ?? 0; }, [summary?.positions, selectedMarket]); const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket); const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket); const selectedTicker = tickers?.get(selectedMarket); const orderbookPrevClose = selectedTicker ? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0) : 0; const orderbookTickerInfo = selectedTicker ? { tradePrice: selectedTicker.tradePrice, changeRate: selectedTicker.changeRate, changePrice: selectedTicker.changePrice, accTradePrice24: selectedTicker.accTradePrice24, accTradeVolume24: selectedTicker.accTradeVolume24, highPrice: selectedTicker.highPrice, lowPrice: selectedTicker.lowPrice, openingPrice: selectedTicker.openingPrice, } : undefined; const handleSelectMarket = useCallback((market: string, price?: number) => { setSelectedMarket(market); const p = price ?? coerceFiniteNumber(tickers?.get(market)?.tradePrice); if (p != null && p > 0) { fillBothSides(market, p, setFillBuy, setFillSell); } setRightTab('trade'); }, [tickers]); /** 알림 목록 아이템 "매매" 버튼 — 우측 패널 열고 해당 종목 현재가 입력 */ const handleTradeFromAlert = useCallback((item: TradeNotificationItem) => { if (!item.isRead) markAsRead(item.id); const price = coerceFiniteNumber(tickers?.get(item.market)?.tradePrice) ?? coerceFiniteNumber(item.price); handleSelectMarket(item.market, price ?? undefined); setRightOpen(true); setRightTab('trade'); }, [markAsRead, tickers, handleSelectMarket]); const selectedCount = selectedId ? 1 : 0; const handleNotificationSelect = useCallback((item: TradeNotificationItem) => { if (!item.isRead) markAsRead(item.id); setSelectedId(prev => { if (prev === item.id) return null; const price = coerceFiniteNumber(item.price); if (price != null && price > 0) { fillBothSides(item.market, price, setFillBuy, setFillSell); } else { handleSelectMarket(item.market); } setRightTab('trade'); return item.id; }); }, [markAsRead, handleSelectMarket]); const handleReportFromAlert = useCallback(async (item: TradeNotificationItem) => { if (!item.isRead) markAsRead(item.id); if (!item.strategyId) { window.alert('전략 정보가 없어 상세분석 레포트를 생성할 수 없습니다.'); return; } if (reportLoadingId) return; const gen = ++reportGenRef.current; setReportLoadingId(item.id); setReportModel(null); setReportOpen(false); try { const strategy = await loadStrategy(item.strategyId); if (gen !== reportGenRef.current) return; if (!strategy) { window.alert('전략을 불러올 수 없습니다.'); return; } const market = item.market.startsWith('KRW-') ? item.market : `KRW-${item.market}`; const timeframe = candleTypeToTimeframe(item.candleType ?? '1m'); const toTimeSec = normalizeEpochSec(item.candleTime); const bars = await loadAnalysisCandles(market, timeframe, toTimeSec, REPORT_BAR_COUNT); if (gen !== reportGenRef.current) return; if (bars.length < 10) { window.alert('캔들 데이터가 부족해 분석 레포트를 생성할 수 없습니다.'); return; } const strategyName = strategy.name || item.strategyName || '전략'; const res = await runBacktest({ strategyId: item.strategyId, bars: bars.map(b => ({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume, })), timeframe, symbol: market, strategyName, settings: DEFAULT_BACKTEST_SETTINGS, indicatorParams: Object.fromEntries( REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record]), ), }); if (gen !== reportGenRef.current) return; if (!res) { window.alert('백테스트 분석에 실패했습니다.'); return; } const model = buildChartBacktestReportModel({ analysis: res.analysis ?? null, stats: res.stats ?? null, signals: res.signals, strategyName, symbol: market.replace(/^KRW-/, ''), timeframe, barCount: bars.length, createdAt: new Date(item.receivedAt).toISOString(), }); if (!model) { window.alert('분석 데이터를 생성할 수 없습니다.'); return; } setReportModel(model); setReportOpen(true); } catch { if (gen !== reportGenRef.current) return; window.alert('상세분석 레포트 생성 중 오류가 발생했습니다.'); } finally { if (gen === reportGenRef.current) setReportLoadingId(null); } }, [markAsRead, reportLoadingId, getParams]); const handleDeleteOne = useCallback(async (item: TradeNotificationItem, e: React.MouseEvent) => { e.stopPropagation(); if (!window.confirm('이 알림을 삭제하시겠습니까?')) return; await deleteNotification(item.id); setSelectedId(prev => (prev === item.id ? null : prev)); }, [deleteNotification]); const handleDeleteSelected = useCallback(async () => { if (!selectedId) return; if (!window.confirm('선택한 알림을 삭제하시겠습니까?')) return; await deleteNotifications([selectedId]); setSelectedId(null); }, [selectedId, deleteNotifications]); const handleDeleteAll = useCallback(async () => { if (allNotifications.length === 0) return; if (!window.confirm(`알림 ${allNotifications.length}건을 모두 삭제하시겠습니까?`)) return; await deleteAllNotifications(); setSelectedId(null); }, [allNotifications.length, deleteAllNotifications]); const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { setRightTab('trade'); const side = rowType === 'bid' ? 'buy' : 'sell'; const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() }; if (side === 'buy') setFillBuy(req); else setFillSell(req); }, [selectedMarket]); const filtered = useMemo(() => { let items = filter === 'unread' ? allNotifications.filter(n => !n.isRead) : allNotifications; if (candleFilter) { items = items.filter( n => normalizeStartCandleType(n.candleType) === candleFilter, ); } if (normalizedQuery) { items = items.filter(n => { const mkt = n.market.toLowerCase(); const sym = n.market.includes('-') ? n.market.split('-')[1].toLowerCase() : mkt; const strat = (n.strategyName ?? '').toLowerCase(); const sigKo = n.signalType === 'BUY' ? '매수' : '매도'; return ( mkt.includes(normalizedQuery) || sym.includes(normalizedQuery) || strat.includes(normalizedQuery) || sigKo.includes(normalizedQuery) ); }); } return items; }, [allNotifications, filter, candleFilter, normalizedQuery]); const sorted = useMemo( () => sortTradeNotifications(filtered, listSort), [filtered, listSort], ); const handleListSortChange = useCallback((sort: TradeNotificationListSort) => { setListSort(sort); saveTradeNotificationListSort(sort); }, []); const handleCandleFilterChange = useCallback((value: TradeNotificationCandleFilter) => { setCandleFilter(value); saveTradeNotificationCandleFilter(value); }, []); const handleListLayoutChange = useCallback((layout: TradeNotificationListLayout) => { setListLayout(layout); saveTradeNotificationListLayout(layout); }, []); const handlePageRefresh = useCallback(async () => { if (refreshing) return; setRefreshing(true); try { await Promise.all([ refreshHistory({ serverOnly: true }), refreshPaperData(), ]); } finally { setRefreshing(false); } }, [refreshing, refreshHistory, refreshPaperData]); const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => { setGridPreset(preset); saveTradeNotificationGridPreset(preset); }, []); const toggleRightPanel = useCallback(() => { setRightOpen(prev => { const next = !prev; storeBool(TNL_RIGHT_OPEN_KEY, next); return next; }); }, []); const isListView = listLayout === 'list'; const gridCols = getTradeNotificationGridPreset(gridPreset).cols; return ( <> {fullscreenItem && ( setFullscreenItem(null)} /> )} setReportOpen(false)} model={reportModel} /> 매매 시그널 알림 {/* 검색란 */} setQuery(e.target.value)} aria-label="알림 목록 검색" /> {query && ( setQuery('')} aria-label="검색어 초기화" > ✕ )} 미확인 {unreadCount}건 {selectedCount > 0 && ( 선택 {selectedCount}건 )} setFilter(e.target.value as TradeNotificationReadFilter)} > {TRADE_NOTIFICATION_READ_FILTER_OPTIONS.map(o => ( {o.label} ))} handleListSortChange(e.target.value as TradeNotificationListSort)} > {TRADE_NOTIFICATION_SORT_OPTIONS.map(o => ( {o.label} ))} handleCandleFilterChange(e.target.value as TradeNotificationCandleFilter)} > 전체 {TRADE_NOTIFICATION_CANDLE_FILTER_OPTIONS.map(o => ( {o.label} ))} {unreadCount > 0 && ( 모두 읽음 )} handleListLayoutChange('list')} > 목록형 handleListLayoutChange('grid')} > 그리드형 void handlePageRefresh()} > 0 ? `선택 알림 삭제 (${selectedCount}건)` : '선택 알림 삭제'} aria-label={selectedCount > 0 ? `선택 알림 ${selectedCount}건 삭제` : '선택 알림 삭제'} disabled={selectedCount === 0} onClick={() => void handleDeleteSelected()} > void handleDeleteAll()} > {sorted.length === 0 ? ( {normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'} ) : ( {sorted.map(item => ( handleNotificationSelect(item)} onDelete={e => void handleDeleteOne(item, e)} onDetail={() => { if (!item.isRead) markAsRead(item.id); openDetail(item, { centered: true }); }} onGoToChart={() => { markAsRead(item.id); setFullscreenItem(item); }} onTrade={() => handleTradeFromAlert(item)} onReport={() => void handleReportFromAlert(item)} reportLoading={reportLoadingId === item.id} tickers={tickers} /> ))} )} {rightOpen ? ( ) : ( )} > ); }; export default TradeNotificationListPage;