/** * 분석레포트 — 가상매매와 동일 3패널 (좌: 목록 · 중: 분석 · 우: 시그널) */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { Theme, Timeframe } from '../../types'; import { loadBacktestResults, deleteBacktestResult, deletePaperTradesBatch, loadPaperTrades, loadPaperSummary, loadStrategies, type BacktestResultRecord, type StrategyDto, } from '../../utils/backendApi'; import { buildContextFromBacktest, buildContextFromLive, } from '../../utils/analysisReportContext'; import { buildLiveExecutionItems, type LiveExecutionItem } from '../../utils/liveExecutionGroups'; import { PAPER_TRADES_CHANGED_EVENT, notifyPaperTradesChanged } from '../../utils/paperTradeEvents'; import BacktestExecutionList, { type ExecutionListTab } from '../backtest/BacktestExecutionList'; import BuilderPageShell from '../layout/BuilderPageShell'; import AnalysisReportCenterPanel from './AnalysisReportCenterPanel'; import AnalysisReportSignalsPanel from './AnalysisReportSignalsPanel'; import AnalysisReportContextBar from './AnalysisReportContextBar'; import BacktestAnalysisReportModal from '../backtest/BacktestAnalysisReportModal'; import { buildBacktestReportModel, buildLiveReportModel, } from '../../utils/backtestReportModel'; import { enrichStrategyNameMap, strategyNamesFromList } from '../../utils/strategyNameResolver'; import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators'; import '../../styles/backtestDashboard.css'; import '../../styles/analysisReportPage.css'; interface Props { theme?: Theme; } function pickCompareBacktest( selected: BacktestResultRecord, records: BacktestResultRecord[], ): BacktestResultRecord | null { const others = records.filter(r => r.id !== selected.id); const sameSymbol = others.filter(r => r.symbol === selected.symbol); return sameSymbol[0] ?? others[0] ?? null; } export function AnalysisReportPage({ theme = 'dark' }: Props) { const [sourceTab, setSourceTab] = useState('backtest'); const [records, setRecords] = useState([]); const [liveItems, setLiveItems] = useState([]); const [selectedBacktest, setSelectedBacktest] = useState(null); const [selectedLive, setSelectedLive] = useState(null); const [strategies, setStrategies] = useState([]); const [loading, setLoading] = useState(true); const [reportOpen, setReportOpen] = useState(false); const refreshLive = useCallback(async (backtestRecords: BacktestResultRecord[] = []) => { const [trades, summary, stratList] = await Promise.all([ loadPaperTrades(), loadPaperSummary(), loadStrategies(), ]); setStrategies(stratList); const baseNames = strategyNamesFromList(stratList); const strategyNames = await enrichStrategyNameMap( baseNames, [ ...trades.map(t => t.strategyId), ...backtestRecords.map(r => r.strategyId), ], ); const live = buildLiveExecutionItems(trades, summary, strategyNames); setLiveItems(live); setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null)); return strategyNames; }, []); const fetchAll = useCallback(async () => { setLoading(true); try { const [recs, stratList] = await Promise.all([ loadBacktestResults(), loadStrategies(), ]); setStrategies(stratList); const strategyNames = await refreshLive(recs); const enrichedRecords = recs.map(r => ({ ...r, strategyName: r.strategyName?.trim() || (r.strategyId != null ? strategyNames[r.strategyId] : undefined) || r.strategyName || '전략 없음', })); setRecords(enrichedRecords); setSelectedBacktest(prev => ( prev && enrichedRecords.some(r => r.id === prev.id) ? prev : enrichedRecords[0] ?? null )); } finally { setLoading(false); } }, [refreshLive]); useEffect(() => { void fetchAll(); }, [fetchAll]); useEffect(() => { const onPaper = () => { void refreshLive(records); }; window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper); return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper); }, [refreshLive, records]); const handleDeleteBacktest = useCallback(async (record: BacktestResultRecord) => { if (record.id == null) return; if (!window.confirm('이 백테스팅 결과를 삭제하시겠습니까?')) return; try { await deleteBacktestResult(record.id); setRecords(prev => { const next = prev.filter(r => r.id !== record.id); setSelectedBacktest(sel => (sel?.id === record.id ? next[0] ?? null : sel)); return next; }); } catch (e) { window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.'); } }, []); const handleDeleteLive = useCallback(async (item: LiveExecutionItem) => { const ids = item.trades.map(t => t.id).filter(id => id > 0); if (ids.length === 0) return; if (!window.confirm(`실시간 매매 이력 ${ids.length}건을 삭제하시겠습니까?`)) return; try { const deleted = await deletePaperTradesBatch(ids); if (deleted <= 0) { window.alert('삭제할 체결 이력을 찾지 못했습니다.'); return; } await refreshLive(records); notifyPaperTradesChanged(); } catch (e) { window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.'); } }, [refreshLive, records]); const compareBacktest = useMemo(() => { if (!selectedBacktest) return null; return pickCompareBacktest(selectedBacktest, records); }, [records, selectedBacktest]); const ctx = useMemo(() => { if (sourceTab === 'backtest' && selectedBacktest) { return buildContextFromBacktest(selectedBacktest, compareBacktest); } if (sourceTab === 'live' && selectedLive) { return buildContextFromLive(selectedLive); } return null; }, [sourceTab, selectedBacktest, selectedLive, compareBacktest]); const strategyNamesById = useMemo( () => Object.fromEntries(strategies.map(s => [s.id, s.name])), [strategies], ); const reportModel = useMemo(() => { if (sourceTab === 'backtest' && selectedBacktest && ctx?.analysis) { return buildBacktestReportModel( selectedBacktest, ctx.analysis, ctx.signals, strategyNamesById, ); } if (sourceTab === 'live' && selectedLive) { return buildLiveReportModel(selectedLive, ctx?.signals ?? []); } return null; }, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]); const timelineFilter = useMemo(() => { if (sourceTab === 'live' && selectedLive) { return { symbol: selectedLive.symbol, strategyId: selectedLive.strategyId, }; } if (sourceTab === 'backtest' && selectedBacktest) { return { symbol: selectedBacktest.symbol, strategyId: selectedBacktest.strategyId ?? null, }; } return undefined; }, [sourceTab, selectedLive, selectedBacktest]); const timelineFilterLabel = useMemo(() => { if (sourceTab === 'live' && selectedLive) { return `${selectedLive.symbol} · ${selectedLive.strategyLabel}`; } if (sourceTab === 'backtest' && selectedBacktest) { return `${selectedBacktest.symbol} · ${selectedBacktest.strategyName}`; } return undefined; }, [sourceTab, selectedLive, selectedBacktest]); const timelineChartTimeframe = useMemo((): Timeframe => { if (sourceTab === 'live' && selectedLive?.timeframe) { return candleTypeToTimeframe(selectedLive.timeframe); } if (sourceTab === 'backtest' && selectedBacktest?.timeframe) { return candleTypeToTimeframe(selectedBacktest.timeframe); } return '3m'; }, [sourceTab, selectedLive, selectedBacktest]); const headerActions = ( <> ); return ( <> } left={( { setSelectedBacktest(r); setSourceTab('backtest'); }} onSelectLive={item => { setSelectedLive(item); setSourceTab('live'); }} onDeleteBacktest={r => void handleDeleteBacktest(r)} onDeleteLive={item => void handleDeleteLive(item)} /> )} center={( )} right={} /> setReportOpen(false)} model={reportModel} /> ); } export default AnalysisReportPage;