백테스팅 분석결과 레포트 출력
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user