백테스팅 분석결과 레포트 출력

This commit is contained in:
Macbook
2026-06-03 12:14:08 +09:00
parent c228f18c25
commit ee7b1a6123
11 changed files with 1207 additions and 15 deletions
@@ -11,6 +11,7 @@ import {
type BacktestResultRecord,
type BacktestAnalysis,
type BacktestSignal,
type StrategyDto,
} from '../utils/backendApi';
import type { Theme } from '../types';
import { buildEquityFromSignals } from '../utils/backtestEquity';
@@ -25,6 +26,11 @@ import BacktestExecutionList, { type ExecutionListTab } from './backtest/Backtes
import BacktestAnalysisChart from './backtest/BacktestAnalysisChart';
import BacktestKpiPanel from './backtest/BacktestKpiPanel';
import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard';
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
import {
buildBacktestReportModel,
buildLiveReportModel,
} from '../utils/backtestReportModel';
import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
import {
@@ -34,6 +40,7 @@ import {
} from './strategyEditor/usePanelResize';
import '../styles/strategyEditorTheme.css';
import { getKoreanName } from '../utils/marketNameCache';
import { repairUtf8Mojibake } from '../utils/textEncoding';
const LEFT_KEY = 'btd-left-width';
const RIGHT_KEY = 'btd-right-width';
@@ -65,6 +72,8 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
const [selectedBacktest, setSelectedBacktest] = useState<BacktestResultRecord | null>(null);
const [selectedLive, setSelectedLive] = useState<LiveExecutionItem | null>(null);
const [loading, setLoading] = useState(true);
const [reportOpen, setReportOpen] = useState(false);
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
@@ -121,6 +130,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
]);
const strategyNames = Object.fromEntries(strategies.map(s => [s.id, s.name]));
const live = buildLiveExecutionItems(trades, summary, strategyNames);
setStrategies(strategies);
setRecords(list);
setLiveItems(live);
setSelectedBacktest(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
@@ -224,6 +234,32 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
const footerTrades = equityData.trades;
const strategyNamesById = useMemo(
() => Object.fromEntries(
strategies.map(s => [s.id, repairUtf8Mojibake(s.name)]),
),
[strategies],
);
const reportModel = useMemo(() => {
if (tab === 'backtest' && selectedBacktest && backtestDetail?.analysis) {
return buildBacktestReportModel(
selectedBacktest,
backtestDetail.analysis,
backtestDetail.signals,
strategyNamesById,
);
}
if (tab === 'live' && selectedLive) {
return buildLiveReportModel(
selectedLive,
activeSignals,
chartProps?.timeframe ?? '1h',
);
}
return null;
}, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, chartProps?.timeframe, strategyNamesById]);
const showRightDetail = tab === 'backtest'
? backtestDetail?.analysis != null
: selectedLive != null;
@@ -305,6 +341,8 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
signals={activeSignals}
strategyId={chartProps.strategyId}
theme={theme}
onOpenReport={() => setReportOpen(true)}
reportDisabled={!reportModel}
/>
) : (
<div className="btd-empty">
@@ -347,6 +385,12 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
</div>
</aside>
</div>
<BacktestAnalysisReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
model={reportModel}
/>
</div>
);
}
@@ -7,6 +7,7 @@
import React, { useState } from 'react';
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { repairUtf8Mojibake } from '../utils/textEncoding';
// ── 포맷 유틸 ────────────────────────────────────────────────────────────────
@@ -60,6 +61,8 @@ export interface BacktestDashboardProps {
timeframe: string;
barCount: number;
createdAt?: string;
/** 레포트·PDF — 시그널 전체 표시·인쇄용 레이아웃 */
reportMode?: boolean;
}
// ── 섹션 제목 ─────────────────────────────────────────────────────────────
@@ -177,8 +180,9 @@ const SIG_COLOR: Record<string, string> = {
export function BacktestDashboard({
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
reportMode = false,
}: BacktestDashboardProps) {
const [sigExpanded, setSigExpanded] = useState(false);
const [sigExpanded, setSigExpanded] = useState(reportMode);
const a = analysis;
if (!a) {
@@ -190,7 +194,7 @@ export function BacktestDashboard({
);
}
const sigLimit = sigExpanded ? signals.length : 8;
const sigLimit = reportMode || sigExpanded ? signals.length : 8;
const periodStr = signals.length >= 2
? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}`
: `${barCount}`;
@@ -198,12 +202,12 @@ export function BacktestDashboard({
const sharpeLevel = a.sharpeRatio >= 2 ? '탁월' : a.sharpeRatio >= 1 ? '우수' : a.sharpeRatio >= 0 ? '보통' : '미흡';
return (
<div className="brd-dashboard">
<div className={`brd-dashboard${reportMode ? ' brd-dashboard--report' : ''}`}>
{/* ── 대시보드 헤더 ── */}
<div className="brd-dash-header">
<div className="brd-dash-header-left">
<div className="brd-dash-strategy">{strategyName}</div>
<div className="brd-dash-strategy">{repairUtf8Mojibake(strategyName)}</div>
<div className="brd-dash-meta">
<span className="brd-dash-badge">{symbol}</span>
<span className="brd-dash-badge">{timeframe}</span>
@@ -211,12 +215,12 @@ export function BacktestDashboard({
{createdAt && <span className="brd-dash-badge brd-dash-badge--time"> {fmtDateStr(createdAt)}</span>}
</div>
</div>
<div className="brd-dash-header-kpi">
<div className="brd-dash-big-kpi">
<div className={`brd-dash-header-kpi${reportMode ? ' brd-dash-header-kpi--report' : ''}`}>
<div className={`brd-dash-big-kpi${reportMode ? ' brd-dash-big-kpi--report' : ''}`}>
<div className={`brd-dash-big-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</div>
<div className="brd-dash-big-label"> </div>
</div>
<div className="brd-dash-big-kpi">
<div className={`brd-dash-big-kpi${reportMode ? ' brd-dash-big-kpi--report' : ''}`}>
<div className={`brd-dash-big-val ${colorCls(a.finalEquity - a.initialCapital)}`}>
{wonFmt(a.finalEquity)}
</div>
@@ -390,7 +394,7 @@ export function BacktestDashboard({
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
<td>
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
{SIG_LABEL[s.type] ?? s.type}
{repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)}
</span>
</td>
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
@@ -402,7 +406,7 @@ export function BacktestDashboard({
</tbody>
</table>
</div>
{signals.length > 8 && (
{!reportMode && signals.length > 8 && (
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
</button>
@@ -51,6 +51,9 @@ interface Props {
/** 투자관리 차트 탭 — 캔들 오버레이 표시/숨김 (세션만, DB 미저장) */
overlayVisibility?: PaperOverlayVisibility;
onToggleOverlay?: (key: PaperOverlayToggleKey) => void;
/** 분석 레포트 팝업 (백테스팅 상단 툴바) */
onOpenReport?: () => void;
reportDisabled?: boolean;
}
const noop = () => {};
@@ -100,6 +103,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
onHistoryLoadingChange,
overlayVisibility,
onToggleOverlay,
onOpenReport,
reportDisabled = false,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const { defaults: appDefaults } = useAppSettings();
@@ -316,6 +321,23 @@ const BacktestAnalysisChart: React.FC<Props> = ({
<span className="btd-analysis-select btd-analysis-select--static">{chartTypeKo}</span>
</div>
<div className="btd-analysis-toolbar-right">
{onOpenReport && (
<button
type="button"
className="btd-analysis-tool btd-analysis-tool--report"
title={reportDisabled ? '분석 결과를 선택하세요' : '투자분석 상세 레포트 (인쇄·PDF)'}
aria-label="분석 레포트"
disabled={reportDisabled}
onClick={onOpenReport}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</button>
)}
<span className="btd-analysis-tool" title="자석 모드"></span>
<span className="btd-analysis-tool" title="드로잉"></span>
<span className="btd-analysis-tool" title="삭제"></span>
@@ -0,0 +1,149 @@
/**
* 백테스팅 — 투자분석 상세 레포트 (출력·PDF 저장용 팝업)
*/
import React, { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { BacktestAnalysis, BacktestSignal } from '../../utils/backendApi';
import { BacktestDashboard } from '../BacktestResultModal';
import { printReportDocument } from '../../utils/printReportDocument';
import '../../styles/backtestReportPrint.css';
export interface BacktestAnalysisReportModel {
analysis: BacktestAnalysis;
signals: BacktestSignal[];
strategyName: string;
symbol: string;
symbolKo?: string;
timeframe: string;
barCount: number;
createdAt?: string;
reportKind: 'backtest' | 'live';
}
interface Props {
open: boolean;
onClose: () => void;
model: BacktestAnalysisReportModel | null;
}
const IcReport = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</svg>
);
const IcPrint = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
);
export default function BacktestAnalysisReportModal({ open, onClose, model }: Props) {
const printAreaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const handlePrint = useCallback(() => {
const root = printAreaRef.current;
if (!root) return;
printReportDocument(
root.innerHTML,
`GoldenChart 투자분석 레포트${model?.symbolKo ? ` · ${model.symbolKo}` : ''}`,
);
}, [model?.symbolKo]);
if (!open || !model) return null;
const printedAt = new Date().toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const content = (
<div
className="btd-report-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="btd-report-title"
onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="btd-report-shell">
<header className="btd-report-chrome btd-report-no-print">
<div className="btd-report-chrome-title">
<span className="btd-report-chrome-icon" aria-hidden><IcReport /></span>
<div>
<h2 id="btd-report-title" className="btd-report-chrome-heading"> </h2>
<p className="btd-report-chrome-sub">
{model.reportKind === 'live' ? '실시간 매매' : '백테스팅'}
{model.symbolKo ? ` · ${model.symbolKo}` : ''}
</p>
</div>
</div>
<div className="btd-report-chrome-actions">
<div className="btd-report-chrome-actions-col">
<button
type="button"
className="btd-report-btn btd-report-btn--primary"
onClick={handlePrint}
title="프린터 또는 PDF로 저장"
>
<IcPrint />
/ PDF
</button>
<p className="btd-report-print-hint">PDF PDF로 · </p>
</div>
<button type="button" className="btd-report-btn" onClick={onClose}></button>
</div>
</header>
<div className="btd-report-scroll">
<div ref={printAreaRef} className="btd-report-print-area" lang="ko">
<div className="btd-report-print-header">
<div className="btd-report-print-brand">
<h1>GoldenChart </h1>
<p className="btd-report-print-meta">
{printedAt}
{model.createdAt ? ` · 분석 실행 ${model.createdAt}` : ''}
</p>
</div>
{model.symbolKo && (
<p className="btd-report-print-symbol">{model.symbolKo}</p>
)}
</div>
<BacktestDashboard
analysis={model.analysis}
signals={model.signals}
strategyName={model.strategyName}
symbol={model.symbol}
timeframe={model.timeframe}
barCount={model.barCount}
createdAt={model.createdAt}
reportMode
/>
<footer className="btd-report-print-footer">
<p> GoldenChart · .</p>
</footer>
</div>
</div>
</div>
</div>
);
return createPortal(content, document.body);
}
@@ -1,17 +1,58 @@
import React from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { TradeHistoryRow } from '../../utils/backtestEquity';
import { pct } from '../../utils/backtestUiUtils';
import {
sortTradeHistoryRows,
type TradeHistorySortDir,
type TradeHistorySortKey,
} from '../../utils/tradeHistoryRowSort';
const fmtDateTime = (ts: number) => {
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const COLUMNS: { key: TradeHistorySortKey; label: string }[] = [
{ key: 'time', label: '일시' },
{ key: 'side', label: '유형' },
{ key: 'price', label: '체결가' },
{ key: 'pnlPct', label: '손익' },
];
interface Props {
trades: TradeHistoryRow[];
}
export default function BacktestTradeHistoryCard({ trades }: Props) {
const [sortKey, setSortKey] = useState<TradeHistorySortKey>('time');
const [sortDir, setSortDir] = useState<TradeHistorySortDir>('asc');
const tradesKey = useMemo(
() => trades.map(t => t.id).join(','),
[trades],
);
useEffect(() => {
setSortKey('time');
setSortDir('asc');
}, [tradesKey]);
const handleSort = useCallback((key: TradeHistorySortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
return prev;
}
setSortDir('asc');
return key;
});
}, []);
const sortedTrades = useMemo(
() => sortTradeHistoryRows(trades, sortKey, sortDir),
[trades, sortKey, sortDir],
);
return (
<section className="rsp-trade-card btd-result-card btd-result-card--trades btd-trades-card">
<h3 className="rsp-trade-card-title">
@@ -25,14 +66,29 @@ export default function BacktestTradeHistoryCard({ trades }: Props) {
<table className="btd-table btd-table--compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
{COLUMNS.map(col => {
const active = sortKey === col.key;
return (
<th key={col.key} aria-sort={active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}>
<button
type="button"
className={`btd-th-sort${active ? ' btd-th-sort--active' : ''}`}
onClick={() => handleSort(col.key)}
>
{col.label}
{active && (
<span className="btd-th-sort-icon" aria-hidden>
{sortDir === 'asc' ? '↑' : '↓'}
</span>
)}
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{trades.map(t => (
{sortedTrades.map(t => (
<tr key={t.id}>
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
<td className={t.side === 'buy' ? 'buy' : 'sell'}>{t.side === 'buy' ? '매수' : '매도'}</td>