Files
goldenChart/frontend/src/components/analysisReport/AnalysisReportPage.tsx
T

290 lines
10 KiB
TypeScript

/**
* 분석레포트 — 가상매매와 동일 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<ExecutionListTab>('backtest');
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
const [liveItems, setLiveItems] = useState<LiveExecutionItem[]>([]);
const [selectedBacktest, setSelectedBacktest] = useState<BacktestResultRecord | null>(null);
const [selectedLive, setSelectedLive] = useState<LiveExecutionItem | null>(null);
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
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 = (
<>
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}>새로고침</button>
<button
type="button"
className="btd-btn btd-btn--gold"
disabled={!reportModel}
onClick={() => setReportOpen(true)}
>
인쇄 / PDF
</button>
</>
);
return (
<>
<BuilderPageShell
theme={theme}
title="분석레포트"
subtitle="Golden Analysis Command Center"
pageClassName="bps-page--arp"
loading={loading}
loadingText="분석레포트 로딩…"
collapsiblePanels
leftStorageKey="arp-left-width"
leftDefaultWidth={380}
leftCollapsedStorageKey="arp-left-open"
rightStorageKey="arp-right-width"
rightDefaultWidth={320}
rightCollapsedStorageKey="arp-right-open"
leftTitle="실행 목록"
headerActions={headerActions}
banner={<AnalysisReportContextBar model={reportModel} />}
left={(
<BacktestExecutionList
tab={sourceTab}
onTabChange={setSourceTab}
backtestRecords={records}
liveItems={liveItems}
selectedBacktestId={selectedBacktest?.id ?? null}
selectedLiveId={selectedLive?.id ?? null}
onSelectBacktest={r => {
setSelectedBacktest(r);
setSourceTab('backtest');
}}
onSelectLive={item => {
setSelectedLive(item);
setSourceTab('live');
}}
onDeleteBacktest={r => void handleDeleteBacktest(r)}
onDeleteLive={item => void handleDeleteLive(item)}
/>
)}
center={(
<AnalysisReportCenterPanel
model={reportModel}
timelineFilter={timelineFilter}
timelineFilterLabel={timelineFilterLabel}
chartTimeframe={timelineChartTimeframe}
/>
)}
right={<AnalysisReportSignalsPanel model={reportModel} />}
/>
<BacktestAnalysisReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
model={reportModel}
/>
</>
);
}
export default AnalysisReportPage;