248 lines
8.6 KiB
TypeScript
248 lines
8.6 KiB
TypeScript
/**
|
|
* 분석레포트 — 좌: 백테스팅/실시간 목록(백테스팅 화면과 동일) · 우: PDF 10 Variations 탭
|
|
*/
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { Theme } from '../../types';
|
|
import {
|
|
loadBacktestResults,
|
|
loadPaperTrades,
|
|
loadPaperSummary,
|
|
loadStrategies,
|
|
type BacktestResultRecord,
|
|
type StrategyDto,
|
|
} from '../../utils/backendApi';
|
|
import type { AnalysisReportTypeId } from '../../utils/analysisReportTypes';
|
|
import { ANALYSIS_REPORT_TYPES } from '../../utils/analysisReportTypes';
|
|
import {
|
|
buildContextFromBacktest,
|
|
buildContextFromLive,
|
|
} from '../../utils/analysisReportContext';
|
|
import { buildLiveExecutionItems, type LiveExecutionItem } from '../../utils/liveExecutionGroups';
|
|
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
|
|
import {
|
|
readStoredSize,
|
|
storeSize,
|
|
usePanelResize,
|
|
} from '../strategyEditor/usePanelResize';
|
|
import BacktestExecutionList, { type ExecutionListTab } from '../backtest/BacktestExecutionList';
|
|
import AnalysisReportViews from './AnalysisReportViews';
|
|
import BacktestAnalysisReportModal from '../backtest/BacktestAnalysisReportModal';
|
|
import {
|
|
buildBacktestReportModel,
|
|
buildLiveReportModel,
|
|
} from '../../utils/backtestReportModel';
|
|
import '../../styles/strategyEditorTheme.css';
|
|
import '../../styles/backtestDashboard.css';
|
|
import '../../styles/analysisReportPage.css';
|
|
|
|
const LEFT_KEY = 'arp-left-width';
|
|
const LEFT_DEFAULT = 380;
|
|
|
|
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 [reportTypeId, setReportTypeId] = useState<AnalysisReportTypeId>('standard');
|
|
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 [leftWidth, setLeftWidth] = useState(() => Math.max(readStoredSize(LEFT_KEY, LEFT_DEFAULT), 300));
|
|
const leftRef = useRef(leftWidth);
|
|
leftRef.current = leftWidth;
|
|
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v));
|
|
|
|
const refreshLive = useCallback(async () => {
|
|
const [trades, summary, stratList] = await Promise.all([
|
|
loadPaperTrades(),
|
|
loadPaperSummary(),
|
|
loadStrategies(),
|
|
]);
|
|
setStrategies(stratList);
|
|
const strategyNames = Object.fromEntries(stratList.map(s => [s.id, s.name]));
|
|
const live = buildLiveExecutionItems(trades, summary, strategyNames);
|
|
setLiveItems(live);
|
|
setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null));
|
|
}, []);
|
|
|
|
const fetchAll = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [recs, stratList] = await Promise.all([
|
|
loadBacktestResults(),
|
|
loadStrategies(),
|
|
]);
|
|
setRecords(recs);
|
|
setStrategies(stratList);
|
|
setSelectedBacktest(prev => (prev && recs.some(r => r.id === prev.id) ? prev : recs[0] ?? null));
|
|
await refreshLive();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [refreshLive]);
|
|
|
|
useEffect(() => { void fetchAll(); }, [fetchAll]);
|
|
useEffect(() => {
|
|
const onPaper = () => { void refreshLive(); };
|
|
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
|
|
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onPaper);
|
|
}, [refreshLive]);
|
|
|
|
const compareBacktest = useMemo(() => {
|
|
if (!selectedBacktest) return null;
|
|
return pickCompareBacktest(selectedBacktest, records);
|
|
}, [records, selectedBacktest]);
|
|
|
|
const compareLive = useMemo(() => {
|
|
if (!selectedLive) return null;
|
|
return liveItems.find(x => x.id !== selectedLive.id) ?? null;
|
|
}, [liveItems, selectedLive]);
|
|
|
|
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 ?? [], '1h');
|
|
}
|
|
return null;
|
|
}, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className={`arp-page se-page se-page--${theme}`}>
|
|
<header className="arp-header">
|
|
<h1 className="arp-header-title">분석레포트</h1>
|
|
</header>
|
|
<div className="arp-loading">데이터 로딩…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`arp-page se-page se-page--${theme}`}>
|
|
<header className="arp-header">
|
|
<div className="arp-header-brand">
|
|
<h1 className="arp-header-title">분석레포트</h1>
|
|
<span className="arp-header-sub">TradingReport UI Design · 10 Variations</span>
|
|
</div>
|
|
<div className="arp-header-actions">
|
|
<button type="button" className="arp-btn" onClick={() => void fetchAll()}>새로고침</button>
|
|
<button
|
|
type="button"
|
|
className="arp-btn arp-btn--gold"
|
|
disabled={!reportModel}
|
|
onClick={() => setReportOpen(true)}
|
|
>
|
|
인쇄 / PDF
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="arp-body">
|
|
<aside
|
|
className="arp-sidebar btd-sidebar"
|
|
style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}
|
|
>
|
|
<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');
|
|
}}
|
|
/>
|
|
</aside>
|
|
|
|
<div
|
|
className="arp-splitter btd-splitter btd-splitter--v"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="좌측 패널 너비"
|
|
onPointerDown={onLeftSplit}
|
|
/>
|
|
|
|
<main className="arp-main">
|
|
<div className="arp-type-tabs" role="tablist" aria-label="레포트 유형">
|
|
{ANALYSIS_REPORT_TYPES.map(t => (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={reportTypeId === t.id}
|
|
className={`arp-type-tab${reportTypeId === t.id ? ' arp-type-tab--on' : ''}`}
|
|
title={t.subtitle}
|
|
onClick={() => setReportTypeId(t.id)}
|
|
>
|
|
<span className="arp-type-tab-num">{t.tabLabel.split(' ')[0]}</span>
|
|
<span className="arp-type-tab-name">{t.tabLabel.replace(/^\d+\s*/, '')}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="arp-viewport">
|
|
<AnalysisReportViews
|
|
typeId={reportTypeId}
|
|
ctx={ctx}
|
|
theme={theme}
|
|
allBacktests={records}
|
|
compareBacktest={compareBacktest}
|
|
compareLive={compareLive}
|
|
/>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<BacktestAnalysisReportModal
|
|
open={reportOpen}
|
|
onClose={() => setReportOpen(false)}
|
|
model={reportModel}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AnalysisReportPage;
|