분석레포트 화면 추가

This commit is contained in:
Macbook
2026-06-03 15:57:02 +09:00
parent 3e17d6cfb8
commit a2ede568e0
48 changed files with 2593 additions and 94 deletions
+19 -63
View File
@@ -7,19 +7,19 @@
import React, { memo, useEffect, useState } from 'react';
import type { Theme } from '../types';
import type { AuthSession } from '../utils/auth';
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'analysis-report' | 'notifications' | 'settings';
interface TopMenuBarProps {
activePage: MenuPage;
theme: Theme;
onPage: (page: MenuPage) => void;
onTheme: () => void;
/** 미확인 매매 시그널 알림 수 */
/** 미확인 매매 시그널 알림 수 — 알림목록 메뉴 뱃지로 표시 */
tradeNotifyUnread?: number;
/** @deprecated 알림 아이콘 버튼 제거됨, 하위 호환 유지 */
onOpenNotifications?: () => void;
/** 모바일 앱 다운로드 모달 */
onOpenAppDownload?: () => void;
@@ -66,6 +66,16 @@ const IcChart = () => (
</svg>
);
const IcAnalysisReport = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 2h8a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/>
<path d="M3 5H2a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h8"/>
<line x1="7" y1="6" x2="11" y2="6"/>
<line x1="7" y1="9" x2="11" y2="9"/>
<line x1="7" y1="12" x2="9" y2="12"/>
</svg>
);
const IcBacktest = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="1" y="9" width="2.5" height="5" rx="0.5"/>
@@ -98,15 +108,6 @@ const IcNotify = () => (
</svg>
);
/** 알림 팝업 전체 닫기 */
const IcDismissPopups = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="9" height="10" rx="1"/>
<path d="M11 5.5 14 2.5M14 5.5 11 2.5"/>
<line x1="4.5" y1="6.5" x2="8.5" y2="10.5"/>
<line x1="8.5" y1="6.5" x2="4.5" y2="10.5"/>
</svg>
);
const IcFullscreen = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@@ -191,6 +192,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'trend-search', label: '추세검색', icon: <IcTrendSearch /> },
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'analysis-report', label: '분석레포트', icon: <IcAnalysisReport /> },
{ page: 'notifications', label: '알림목록', icon: <IcNotify /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> },
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
@@ -199,7 +201,6 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
export const TopMenuBar = memo(function TopMenuBar({
activePage, theme, onPage, onTheme,
tradeNotifyUnread = 0,
onOpenNotifications,
onOpenAppDownload,
tradeNotifyToastCount = 0,
onDismissAllNotifyPopups,
@@ -227,8 +228,6 @@ export const TopMenuBar = memo(function TopMenuBar({
const visibleItems = menuPermissions
? MENU_ITEMS.filter(({ page }) => canAccessMenu(menuPermissions, page))
: MENU_ITEMS;
const showNotifications = menuPermissions == null || menuPermissions.notifications === true;
return (
<header className="top-menubar">
{/* 로고 */}
@@ -254,6 +253,11 @@ export const TopMenuBar = memo(function TopMenuBar({
>
<span className="tmb-nav-icon">{icon}</span>
<span className="tmb-nav-label">{label}</span>
{page === 'notifications' && tradeNotifyUnread > 0 && (
<span className="tmb-notify-badge tmb-notify-badge--nav">
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
</span>
)}
</button>
</React.Fragment>
))}
@@ -274,54 +278,6 @@ export const TopMenuBar = memo(function TopMenuBar({
>
<IcAppDownload />
</button>
{onOpenNotifications && showNotifications && (
<span className="tmb-divider" aria-hidden="true" />
)}
</>
)}
{onOpenNotifications && showNotifications && (
<>
<div className="tmb-notify-group">
<button
type="button"
className={`tmb-notify-btn ${activePage === 'notifications' ? 'tmb-nav-btn--active' : ''}`}
onClick={onOpenNotifications}
title="매매 시그널 알림"
aria-label="매매 시그널 알림"
>
<IcNotify />
{tradeNotifyUnread > 0 && (
<span className="tmb-notify-badge">
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
</span>
)}
</button>
<TradeAlertPopupMenubarSelect
position={tradeAlertPopupPosition}
layout={tradeAlertPopupLayout}
gridCols={tradeAlertPopupGridCols}
onPositionChange={onTradeAlertPopupPosition}
onLayoutChange={onTradeAlertPopupLayout}
onGridColsChange={onTradeAlertPopupGridCols}
/>
{onDismissAllNotifyPopups && (
<button
type="button"
className="tmb-notify-dismiss-btn"
onClick={onDismissAllNotifyPopups}
disabled={tradeNotifyToastCount <= 0}
title={
tradeNotifyToastCount > 0
? `알림 팝업 ${tradeNotifyToastCount}건 전체 닫기`
: '닫을 알림 팝업 없음'
}
aria-label="알림 팝업 전체 닫기"
>
<IcDismissPopups />
</button>
)}
</div>
<span className="tmb-divider" aria-hidden="true" />
</>
)}
@@ -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>
);
}
@@ -54,6 +54,8 @@ interface Props {
/** 분석 레포트 팝업 (백테스팅 상단 툴바) */
onOpenReport?: () => void;
reportDisabled?: boolean;
/** 거래 로그 클릭 시 해당 시점으로 차트 이동 */
focusTimeSec?: number | null;
}
const noop = () => {};
@@ -105,6 +107,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
onToggleOverlay,
onOpenReport,
reportDisabled = false,
focusTimeSec = null,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const { defaults: appDefaults } = useAppSettings();
@@ -300,6 +303,15 @@ const BacktestAnalysisChart: React.FC<Props> = ({
applyMarkers();
}, [signals, bars, applyMarkers]);
useEffect(() => {
if (focusTimeSec == null) return;
const mgr = managerRef.current;
if (!mgr) return;
const barSec = timeframeBarSeconds(timeframeRaw);
const pad = Math.max(barSec * 48, 3600);
mgr.applyVisibleTimeRange(focusTimeSec - pad, focusTimeSec + pad);
}, [focusTimeSec, timeframeRaw]);
useEffect(() => {
const mgr = managerRef.current;
if (mgr) applyPaneRatio(mgr);