diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index 20eda41..8cbe73b 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -5,7 +5,20 @@ 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 { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi'; +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, @@ -51,6 +64,10 @@ 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 = () => ( @@ -128,7 +145,11 @@ export const TradeNotificationListPage: React.FC = ({ () => loadTradeNotificationGridPreset(), ); const [selectedMarket, setSelectedMarket] = useState(defaultMarket); - const [selectedIds, setSelectedIds] = useState>(() => new Set()); + 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); @@ -138,6 +159,7 @@ export const TradeNotificationListPage: React.FC = ({ 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 () => { @@ -200,54 +222,123 @@ export const TradeNotificationListPage: React.FC = ({ setRightTab('trade'); }, [markAsRead, tickers, handleSelectMarket]); - const selectedCount = selectedIds.size; + const selectedCount = selectedId ? 1 : 0; const handleNotificationSelect = useCallback((item: TradeNotificationItem) => { if (!item.isRead) markAsRead(item.id); - setSelectedIds(prev => { - const adding = !prev.has(item.id); - const next = new Set(prev); - if (adding) { - next.add(item.id); - const price = coerceFiniteNumber(item.price); - if (price != null && price > 0) { - fillBothSides(item.market, price, setFillBuy, setFillSell); - } else { - handleSelectMarket(item.market); - } - setRightTab('trade'); + 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 { - next.delete(item.id); + handleSelectMarket(item.market); } - return next; + 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); - setSelectedIds(prev => { - if (!prev.has(item.id)) return prev; - const next = new Set(prev); - next.delete(item.id); - return next; - }); + setSelectedId(prev => (prev === item.id ? null : prev)); }, [deleteNotification]); const handleDeleteSelected = useCallback(async () => { - if (selectedCount === 0) return; - const ids = [...selectedIds]; - if (!window.confirm(`선택한 알림 ${ids.length}건을 삭제하시겠습니까?`)) return; - await deleteNotifications(ids); - setSelectedIds(new Set()); - }, [selectedCount, selectedIds, deleteNotifications]); + 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(); - setSelectedIds(new Set()); + setSelectedId(null); }, [allNotifications.length, deleteAllNotifications]); const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { @@ -342,6 +433,11 @@ export const TradeNotificationListPage: React.FC = ({ onClose={() => setFullscreenItem(null)} /> )} + setReportOpen(false)} + model={reportModel} + />
= ({ item={item} theme={theme} layoutMode={listLayout} - isSelected={selectedIds.has(item.id)} + isSelected={selectedId === item.id} chartsEnabled={isListView} chartLiveReceiveHighlight={chartLiveReceiveHighlight} onSelect={() => handleNotificationSelect(item)} @@ -520,6 +616,8 @@ export const TradeNotificationListPage: React.FC = ({ setFullscreenItem(item); }} onTrade={() => handleTradeFromAlert(item)} + onReport={() => void handleReportFromAlert(item)} + reportLoading={reportLoadingId === item.id} tickers={tickers} /> ))} diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index 08da18f..df8f772 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -37,6 +37,16 @@ const IcTrash = () => ( ); +const IcReport = () => ( + + + + + + + +); + export type TradeNotificationRowLayout = 'list' | 'grid'; interface Props { @@ -53,6 +63,9 @@ interface Props { onGoToChart: () => void; /** 매매 버튼 클릭 → 우측 매매 패널 열기 */ onTrade?: () => void; + /** 상세분석 레포트 버튼 클릭 */ + onReport?: () => void; + reportLoading?: boolean; } const MAX_INDICATOR_CARDS = 8; @@ -70,6 +83,8 @@ const TradeNotificationListRow: React.FC = ({ onDetail, onGoToChart, onTrade, + onReport, + reportLoading = false, }) => { useTradeAlertTimeFormat(); const isBuy = item.signalType === 'BUY'; @@ -317,6 +332,18 @@ const TradeNotificationListRow: React.FC = ({ 매매 )} + {onReport && ( + + )}
); diff --git a/frontend/src/styles/tradeNotificationList.css b/frontend/src/styles/tradeNotificationList.css index fe9450b..0e13578 100644 --- a/frontend/src/styles/tradeNotificationList.css +++ b/frontend/src/styles/tradeNotificationList.css @@ -774,6 +774,35 @@ flex-shrink: 0; } +.tnl-row-report { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin-left: 2px; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text3); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + flex-shrink: 0; +} +.tnl-row-report:hover:not(:disabled) { + background: color-mix(in srgb, var(--se-gold, #e6c200) 14%, transparent); + color: var(--se-gold, #e6c200); + border-color: color-mix(in srgb, var(--se-gold, #e6c200) 40%, transparent); +} +.tnl-row-report:disabled { + opacity: 0.45; + cursor: wait; +} +.tnl-row-report--loading svg { + animation: spin 0.8s linear infinite; +} + .tnl-charts-expanded-inner { display: flex; flex-direction: column;