분석레포트 화면 추가
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 분석레포트 — 좌: 백테스팅/실시간 목록(백테스팅 화면과 동일) · 우: 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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* PDF TradingReport 공통 UI (글래스 패널·차트·히트맵)
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { pct, pctAbs } from '../shared/analysisDashboardUi';
|
||||
|
||||
export type PanelTone = 'gold' | 'cyan' | 'neutral';
|
||||
|
||||
export function GlassPanel({
|
||||
tone = 'gold',
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
tone?: PanelTone;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`arp-panel arp-panel--${tone} ${className}`.trim()}>
|
||||
{(title || subtitle) && (
|
||||
<header className="arp-panel-head">
|
||||
{title && <h3 className="arp-panel-title">{title}</h3>}
|
||||
{subtitle && <p className="arp-panel-sub">{subtitle}</p>}
|
||||
</header>
|
||||
)}
|
||||
<div className="arp-panel-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** PDF Assets Growth — 영역 채우기 라인 */
|
||||
export function EquityGrowthChart({
|
||||
values,
|
||||
height = 140,
|
||||
positive = true,
|
||||
}: {
|
||||
values: number[];
|
||||
height?: number;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
const w = 400;
|
||||
const path = useMemo(() => {
|
||||
if (values.length < 2) return { line: '', area: '' };
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const range = hi - lo || 1;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = height - 12 - ((v - lo) / range) * (height - 24);
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const line = pts.join(' ');
|
||||
const area = `${line} L ${w},${height} L 0,${height} Z`;
|
||||
return { line, area };
|
||||
}, [values, height]);
|
||||
|
||||
const stroke = positive ? '#00e5ff' : '#ff1744';
|
||||
const fillId = positive ? 'arpEqCyan' : 'arpEqRed';
|
||||
|
||||
return (
|
||||
<svg className="arp-equity-chart" viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={path.area} fill={`url(#${fillId})`} />
|
||||
<path d={path.line} fill="none" stroke={stroke} strokeWidth="2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonthlyHeatmap({
|
||||
cells,
|
||||
showValues = false,
|
||||
}: {
|
||||
cells: { year: number; month: number; returnPct: number }[];
|
||||
showValues?: boolean;
|
||||
}) {
|
||||
const months = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
||||
const years = [...new Set(cells.map(c => c.year))].sort();
|
||||
const map = new Map(cells.map(c => [`${c.year}-${c.month}`, c.returnPct]));
|
||||
|
||||
return (
|
||||
<div className="arp-heatmap arp-heatmap--rich">
|
||||
<div className="arp-heatmap-head">
|
||||
<span />
|
||||
{months.map(m => <span key={m}>{m}</span>)}
|
||||
</div>
|
||||
{years.map(y => (
|
||||
<div key={y} className="arp-heatmap-row">
|
||||
<span className="arp-heatmap-y">{y}</span>
|
||||
{months.map((_, mi) => {
|
||||
const v = map.get(`${y}-${mi}`) ?? 0;
|
||||
const intensity = Math.min(1, Math.abs(v) * 6);
|
||||
const bg = v > 0
|
||||
? `rgba(0, 230, 118, ${0.2 + intensity * 0.7})`
|
||||
: v < 0
|
||||
? `rgba(255, 23, 68, ${0.2 + intensity * 0.7})`
|
||||
: 'rgba(60,70,90,0.35)';
|
||||
return (
|
||||
<span key={mi} className="arp-heatmap-cell" style={{ background: bg }} title={pct(v)}>
|
||||
{showValues && v !== 0 && (
|
||||
<span className="arp-heatmap-val">{pctAbs(v, 0)}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrafficLight({ level }: { level: 'green' | 'yellow' | 'red' }) {
|
||||
return (
|
||||
<div className="arp-traffic">
|
||||
<span className={`arp-traffic-bulb${level === 'red' ? ' arp-traffic-bulb--on' : ''}`} data-c="r" />
|
||||
<span className={`arp-traffic-bulb${level === 'yellow' ? ' arp-traffic-bulb--on' : ''}`} data-c="y" />
|
||||
<span className={`arp-traffic-bulb${level === 'green' ? ' arp-traffic-bulb--on' : ''}`} data-c="g" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DonutAllocation({
|
||||
slices,
|
||||
}: {
|
||||
slices: { label: string; pct: number; color: string }[];
|
||||
}) {
|
||||
const total = slices.reduce((s, x) => s + x.pct, 0) || 1;
|
||||
let acc = 0;
|
||||
const r = 40;
|
||||
const c = 2 * Math.PI * r;
|
||||
return (
|
||||
<div className="arp-donut-wrap">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||
{slices.map((sl, i) => {
|
||||
const dash = (sl.pct / total) * c;
|
||||
const offset = c * 0.25 - acc;
|
||||
acc += dash;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={sl.color}
|
||||
strokeWidth="14"
|
||||
strokeDasharray={`${dash} ${c - dash}`}
|
||||
strokeDashoffset={offset}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<ul className="arp-donut-legend">
|
||||
{slices.map(sl => (
|
||||
<li key={sl.label}>
|
||||
<span className="arp-donut-dot" style={{ background: sl.color }} />
|
||||
{sl.label} {sl.pct.toFixed(1)}%
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiTile({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
variant = 'gold',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
variant?: 'gold' | 'green' | 'red';
|
||||
}) {
|
||||
return (
|
||||
<div className={`arp-kpi-tile arp-kpi-tile--${variant}`}>
|
||||
<span className="arp-kpi-tile-label">{label}</span>
|
||||
<strong className="arp-kpi-tile-value">{value}</strong>
|
||||
{sub && <span className="arp-kpi-tile-sub">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user