/** * 매매 시그널 알림 목록 화면 — 좌: 알림 목록, 우: 매매·호가 (가상매매 우측 패널과 동일) */ 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 { loadBacktestSettings, loadPaperSummary, runBacktest, type PaperSummaryDto, type StrategyDto, } from '../utils/backendApi'; import { prefetchNotificationStrategies, resolveNotificationStrategy } from '../utils/resolveNotificationStrategy'; import { normalizeMarketCode } from '../utils/strategyHydrate'; import { fetchBacktestCandleBundle } from '../utils/backtestWarmup'; 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/paperDashboard.css'; import '../styles/tradeRightPanel.css'; import '../styles/tradeNotificationList.css'; import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow'; import { VirtualGridScroll, VirtualScroll } from './common/VirtualScroll'; 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'; import { getKoreanName } from '../utils/marketNameCache'; type RightTab = 'trade' | 'orderbook'; const TNL_RIGHT_OPEN_KEY = 'tnl-right-open'; 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 [strategies, setStrategies] = useState([]); 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]); useEffect(() => { let cancelled = false; let retryTimer: ReturnType | null = null; const load = () => { void prefetchNotificationStrategies().then(list => { if (cancelled) return; if (list.length > 0) { setStrategies(list); } else { // 백엔드 미준비(Docker 기동 초기)로 빈 목록이 반환된 경우 5초 후 재시도 retryTimer = setTimeout(load, 5000); } }); }; load(); return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer); }; }, []); 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 && !item.strategyName?.trim()) { window.alert('전략 정보가 없어 상세분석 레포트를 생성할 수 없습니다.'); return; } if (reportLoadingId) return; const gen = ++reportGenRef.current; setReportLoadingId(item.id); setReportModel(null); setReportOpen(false); try { const { strategy, strategyId: effectiveStrategyId } = await resolveNotificationStrategy( item.strategyId, item.strategyName, strategies, item.market, ); if (gen !== reportGenRef.current) return; if (!strategy || effectiveStrategyId == null) { window.alert('전략을 불러올 수 없습니다.'); return; } const market = normalizeMarketCode(item.market); const timeframe = candleTypeToTimeframe(item.candleType ?? '1m'); const toTimeSec = normalizeEpochSec(item.candleTime); const indicatorParams = Object.fromEntries( REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record]), ); const { bars, evaluationBarCount } = await fetchBacktestCandleBundle({ market, timeframe, toTimeSec, buyDsl: strategy.buyCondition, sellDsl: strategy.sellCondition, indicatorParams, }); if (gen !== reportGenRef.current) return; if (bars.length < 10) { window.alert('캔들 데이터가 부족해 분석 레포트를 생성할 수 없습니다.'); return; } const strategyName = strategy.name || item.strategyName || '전략'; const btSettings = await loadBacktestSettings(); const res = await runBacktest({ strategyId: effectiveStrategyId, 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: btSettings, indicatorParams, evaluationBarCount, }); 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, strategies]); 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 ko = ( tickers?.get(n.market)?.koreanName ?? getKoreanName(n.market) ).toLowerCase(); const strat = (n.strategyName ?? '').toLowerCase(); const sigKo = n.signalType === 'BUY' ? '매수' : '매도'; return ( ko.includes(normalizedQuery) || mkt.includes(normalizedQuery) || sym.includes(normalizedQuery) || strat.includes(normalizedQuery) || sigKo.includes(normalizedQuery) ); }); } return items; }, [allNotifications, filter, candleFilter, normalizedQuery, tickers]); 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; const renderNotificationRow = useCallback((item: TradeNotificationItem) => ( 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} ticker={tickers?.get(normalizeMarketCode(item.market)) ?? tickers?.get(item.market)} /> ), [ theme, listLayout, isListView, selectedId, chartLiveReceiveHighlight, handleNotificationSelect, handleDeleteOne, markAsRead, openDetail, handleTradeFromAlert, handleReportFromAlert, reportLoadingId, tickers, strategies, ]); return ( <> {fullscreenItem && ( setFullscreenItem(null)} /> )} setReportOpen(false)} model={reportModel} />

매매 시그널 알림

{/* 검색란 */}
setQuery(e.target.value)} aria-label="알림 목록 검색" /> {query && ( )}
미확인 {unreadCount}건 {selectedCount > 0 && ( 선택 {selectedCount}건 )}
{unreadCount > 0 && ( )}
{sorted.length === 0 ? (
{normalizedQuery ? `"${query}" 에 해당하는 알림이 없습니다.` : '표시할 알림이 없습니다.'}
) : isListView ? ( item.id} role="list" aria-label="매매 시그널 알림 목록" > {item => renderNotificationRow(item)} ) : ( item.id} role="list" aria-label="매매 시그널 알림 그리드" > {item => renderNotificationRow(item)} )}
); }; export default TradeNotificationListPage;