290 lines
10 KiB
TypeScript
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;
|