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

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>
+31
View File
@@ -419,6 +419,35 @@
white-space: nowrap;
}
.btd-th-sort {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 0;
margin: 0;
border: none;
background: transparent;
font: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
white-space: nowrap;
line-height: 1.2;
}
.btd-th-sort:hover {
color: var(--text);
}
.btd-th-sort--active {
color: var(--text);
}
.btd-th-sort-icon {
font-size: 0.85em;
opacity: 0.85;
}
.btd-table td {
padding: 7px 12px;
border-bottom: 1px solid var(--sep);
@@ -1246,6 +1275,8 @@
color: var(--se-text-muted);
font-size: 0.82rem;
cursor: default;
background: transparent;
padding: 0;
}
.btd-analysis-main {
+528
View File
@@ -0,0 +1,528 @@
/* ── 분석 레포트 팝업 (화면 = 인쇄용지 미리보기) ── */
.btd-report-overlay {
position: fixed;
inset: 0;
z-index: 9400;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(30, 32, 38, 0.72);
}
.btd-report-shell {
display: flex;
flex-direction: column;
width: min(920px, 100%);
max-height: min(94vh, 960px);
background: #3d4249;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.btd-report-chrome {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
background: #2a2e35;
}
.btd-report-chrome-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.btd-report-chrome-icon {
display: flex;
color: #c9a227;
}
.btd-report-chrome-heading {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #f0f0f0;
}
.btd-report-chrome-sub {
margin: 2px 0 0;
font-size: 0.72rem;
color: #9ca3af;
}
.btd-report-chrome-actions {
display: flex;
align-items: flex-start;
gap: 12px;
flex-shrink: 0;
}
.btd-report-chrome-actions-col {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.btd-report-print-hint {
margin: 0;
font-size: 0.62rem;
color: #b8bcc4;
text-align: right;
line-height: 1.4;
word-break: keep-all;
}
/* 크롬 바 전용 버튼 (btd-btn·--se-text 상속으로 글자가 안 보이는 문제 방지) */
.btd-report-chrome .btd-report-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
font-size: 0.8rem;
font-weight: 600;
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btd-report-chrome .btd-report-btn:hover {
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
}
.btd-report-chrome .btd-report-btn--primary {
background: rgba(37, 99, 235, 0.35);
border-color: rgba(147, 197, 253, 0.65);
color: #fff;
}
.btd-report-chrome .btd-report-btn--primary:hover {
background: rgba(37, 99, 235, 0.5);
border-color: #93c5fd;
}
/* 책상 + A4 용지 */
.btd-report-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 28px 24px 36px;
background: #9ca3af;
background: linear-gradient(160deg, #a8adb5 0%, #8b919a 100%);
}
.btd-report-print-area {
box-sizing: border-box;
width: 100%;
max-width: 210mm;
min-height: auto;
margin: 0 auto;
padding: 14mm 12mm 16mm;
background: #fff;
color: #111827;
border-radius: 2px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 8px 24px rgba(0, 0, 0, 0.18);
font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif;
letter-spacing: normal;
word-break: keep-all;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
font-feature-settings: 'kern' 1;
}
/* ── 용지 위 레포트 테마 (화면·인쇄 iframe 공통) ── */
.btd-report-print-area,
.btd-report-print-doc {
--text: #111827;
--text2: #374151;
--text3: #6b7280;
--bg1: #ffffff;
--bg2: #f9fafb;
--bg3: #f3f4f6;
--bg4: #e5e7eb;
--border: #d1d5db;
--brd-pos: #059669;
--brd-neg: #dc2626;
--brd-warn: #d97706;
--brd-blue: #2563eb;
--brd-purple: #7c3aed;
--brd-teal: #0d9488;
--brd-yellow: #ca8a04;
--brd-green: #16a34a;
}
.btd-report-print-doc {
margin: 0;
padding: 0;
background: #fff;
font-family: 'Noto Sans KR', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
letter-spacing: normal;
}
.btd-report-print-header {
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 2px solid #c9a227;
}
.btd-report-print-brand h1 {
margin: 0 0 6px;
font-size: 1.2rem;
font-weight: 800;
color: #111827;
letter-spacing: -0.02em;
}
.btd-report-print-meta {
margin: 0;
font-size: 0.7rem;
color: #6b7280;
}
.btd-report-print-symbol {
margin: 8px 0 0;
font-size: 1.05rem;
font-weight: 700;
color: #92400e;
}
.btd-report-print-footer {
margin-top: 18px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
font-size: 0.62rem;
color: #6b7280;
text-align: center;
}
.btd-report-print-footer p {
margin: 0;
}
.btd-report-print-area .brd-dashboard,
.btd-report-print-area .brd-dashboard--report {
padding: 0;
background: #fff !important;
min-height: auto !important;
height: auto !important;
gap: 10px;
overflow: visible !important;
}
.btd-report-print-area .brd-sig-scroll {
max-height: none !important;
overflow: visible !important;
}
.btd-report-print-area .brd-dash-header {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center !important;
gap: 12px 20px !important;
background: linear-gradient(135deg, #eff6ff 0%, #f5f3ff 100%) !important;
border: 1px solid #bfdbfe !important;
}
.btd-report-print-area .brd-dash-header-left {
min-width: 0;
}
.btd-report-print-area .brd-dash-strategy {
font-size: 0.95rem !important;
font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif !important;
letter-spacing: normal !important;
word-break: keep-all !important;
overflow-wrap: anywhere;
line-height: 1.45 !important;
white-space: normal !important;
}
.btd-report-print-area .brd-dash-header-kpi {
display: flex !important;
flex-shrink: 0;
gap: 14px !important;
align-items: flex-start;
}
.btd-report-print-area .brd-dash-big-kpi {
text-align: right;
min-width: 4.8rem;
flex-shrink: 0;
}
.btd-report-print-area .brd-dash-big-kpi:last-child {
min-width: 7.5rem;
}
/* 레포트: 라벨·수치 한 줄 (총 수익률 -0.04% 형태) */
.btd-report-print-area .brd-dash-header-kpi--report {
align-items: center !important;
}
.btd-report-print-area .brd-dash-big-kpi--report {
display: inline-flex !important;
flex-direction: row !important;
align-items: baseline !important;
gap: 5px !important;
white-space: nowrap !important;
}
.btd-report-print-area .brd-dash-big-kpi--report .brd-dash-big-label {
order: -1;
margin-top: 0 !important;
}
.btd-report-print-area .brd-dash-big-val {
font-size: 1.05rem !important;
font-weight: 800 !important;
letter-spacing: -0.02em !important;
line-height: 1.15 !important;
white-space: nowrap !important;
}
.btd-report-print-area .brd-dash-big-label {
font-size: 0.62rem !important;
margin-top: 2px !important;
white-space: nowrap !important;
}
.btd-report-print-area .brd-dash-strategy,
.btd-report-print-area .brd-kpi-value,
.btd-report-print-area .brd-metric-value {
color: #111827 !important;
}
.btd-report-print-area .brd-dash-badge {
background: #dbeafe !important;
color: #1d4ed8 !important;
border-color: #93c5fd !important;
}
.btd-report-print-area .brd-dash-badge--time {
background: #f3f4f6 !important;
color: #6b7280 !important;
border-color: #d1d5db !important;
}
.btd-report-print-area .brd-kpi-card,
.btd-report-print-area .brd-card {
background: #f9fafb !important;
border: 1px solid #e5e7eb !important;
box-shadow: none !important;
overflow: visible !important;
}
.btd-report-print-area .brd-kpi-card:hover {
transform: none !important;
}
/* KPI·지표 한글 잘림(ellipsis) 방지 — 「미흡」 등이 깨져 보이던 원인 */
.btd-report-print-area .brd-kpi-sub,
.btd-report-print-area .brd-kpi-label,
.btd-report-print-area .brd-metric-label,
.btd-report-print-area .brd-metric-note,
.btd-report-print-area .brd-section-text,
.btd-report-print-area .brd-dash-strategy,
.btd-report-print-area .brd-compare-label,
.btd-report-print-area .brd-pnl-label,
.btd-report-print-area .brd-bench-label {
overflow: visible !important;
text-overflow: unset !important;
white-space: normal !important;
word-break: keep-all !important;
letter-spacing: normal !important;
line-height: 1.4 !important;
}
.btd-report-print-area .brd-kpi-sub {
min-height: 1.2em;
}
.btd-report-print-area .brd-dash-strategy {
letter-spacing: normal !important;
}
.btd-report-print-area .brd-kpi-value,
.btd-report-print-area .brd-metric-value {
line-height: 1.25 !important;
}
.btd-report-print-area .brd-section-title {
background: #f3f4f6 !important;
border-bottom-color: #e5e7eb !important;
color: #374151 !important;
}
.btd-report-print-area .brd-metric-label,
.btd-report-print-area .brd-kpi-label {
color: #6b7280 !important;
}
.btd-report-print-area .brd-dash-big-label {
color: #6b7280 !important;
}
.btd-report-print-area .brd-compare-track,
.btd-report-print-area .brd-pnl-track,
.btd-report-print-area .brd-bench-summary {
background: #e5e7eb !important;
}
.btd-report-print-area .brd-sig-table thead {
background: #f3f4f6 !important;
}
.btd-report-print-area .brd-sig-table th,
.btd-report-print-area .brd-sig-table td {
border-color: #e5e7eb !important;
color: #111827 !important;
}
.btd-report-print-doc .btd-report-print-area {
max-width: none;
min-height: auto;
margin: 0;
box-shadow: none;
border-radius: 0;
}
.btd-report-print-doc .brd-kpi-sub,
.btd-report-print-doc .brd-kpi-label,
.btd-report-print-doc .brd-metric-label {
overflow: visible !important;
text-overflow: unset !important;
white-space: normal !important;
word-break: keep-all !important;
}
.btd-report-print-doc .brd-dash-header {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center !important;
}
.btd-report-print-doc .brd-dash-header-kpi {
flex-shrink: 0;
gap: 14px !important;
}
.btd-report-print-doc .brd-dash-big-kpi {
min-width: 4.8rem;
}
.btd-report-print-doc .brd-dash-big-kpi:last-child {
min-width: 7.5rem;
}
.btd-report-print-doc .brd-dash-big-val {
font-size: 1.05rem !important;
white-space: nowrap !important;
}
.btd-report-print-doc .brd-dash-big-label {
font-size: 0.62rem !important;
white-space: nowrap !important;
}
.btd-analysis-tool--report {
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btd-analysis-tool--report:hover:not(:disabled) {
color: var(--btd-gold, #d4af37);
border-color: var(--btd-gold-border, rgba(212, 175, 55, 0.5));
background: color-mix(in srgb, var(--btd-gold, #d4af37) 12%, transparent);
}
.btd-analysis-tool--report:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* iframe·PDF 인쇄 — 다페이지 허용, 하단 잘림 방지 */
@media print {
html,
body.btd-report-print-doc {
height: auto !important;
overflow: visible !important;
background: #fff !important;
}
.btd-report-print-doc {
padding: 0;
margin: 0;
}
.btd-report-print-doc .btd-report-print-area {
padding: 0;
box-shadow: none;
max-width: none;
min-height: 0 !important;
height: auto !important;
overflow: visible !important;
}
.btd-report-print-doc .brd-dashboard {
break-inside: auto !important;
page-break-inside: auto !important;
min-height: 0 !important;
}
.btd-report-print-doc .brd-row,
.btd-report-print-doc .brd-row--2col,
.btd-report-print-doc .brd-row--3col,
.btd-report-print-doc .brd-card,
.btd-report-print-doc .brd-card--full,
.btd-report-print-doc .brd-kpi-grid {
break-inside: auto !important;
page-break-inside: auto !important;
overflow: visible !important;
}
.btd-report-print-doc .brd-kpi-card,
.btd-report-print-doc .brd-dash-header {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
.btd-report-print-doc .brd-sig-scroll {
max-height: none !important;
overflow: visible !important;
height: auto !important;
}
.btd-report-print-doc .brd-expand-btn {
display: none !important;
}
.btd-report-print-doc .brd-card,
.btd-report-print-doc .brd-kpi-card,
.btd-report-print-doc .brd-dash-header,
.btd-report-print-doc .btd-report-print-footer {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
size: A4 portrait;
margin: 10mm;
}
}
+89
View File
@@ -0,0 +1,89 @@
import type {
BacktestAnalysis,
BacktestResultRecord,
BacktestSignal,
} from './backendApi';
import type { BacktestAnalysisReportModel } from '../components/backtest/BacktestAnalysisReportModal';
import type { LiveExecutionItem } from './liveExecutionGroups';
import { fmtListTimestamp, toUpbitMarket } from './backtestUiUtils';
import { getKoreanName } from './marketNameCache';
import { repairUtf8Mojibake } from './textEncoding';
/** 실시간 매매 탭 — KPI만 있는 경우 최소 분석 객체 */
export function buildAnalysisFromLive(
item: LiveExecutionItem,
initialCapital = 10_000_000,
): BacktestAnalysis {
const finalEquity = initialCapital * (1 + item.totalReturnPct);
const winCount = Math.round(item.tradeCount * item.winRatePct);
return {
initialCapital,
finalEquity,
totalReturnPct: item.totalReturnPct,
totalProfitLoss: finalEquity - initialCapital,
grossProfit: 0,
grossLoss: 0,
avgReturnPct: item.tradeCount > 0 ? item.totalReturnPct / item.tradeCount : 0,
profitLossRatio: item.profitFactor,
numberOfPositions: item.tradeCount,
numberOfWinning: winCount,
numberOfLosing: Math.max(0, item.tradeCount - winCount),
numberOfBreakEven: 0,
winRate: item.winRatePct,
maxDrawdownPct: item.mddPct,
maxRunupPct: 0,
sharpeRatio: item.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
}
export function buildBacktestReportModel(
record: BacktestResultRecord,
analysis: BacktestAnalysis,
signals: BacktestSignal[],
strategyNamesById: Record<number, string> = {},
): BacktestAnalysisReportModel {
const market = toUpbitMarket(record.symbol);
const freshName = record.strategyId != null
? strategyNamesById[record.strategyId]
: undefined;
const strategyName = repairUtf8Mojibake(
freshName || record.strategyName || '전략 없음',
);
return {
analysis,
signals,
strategyName,
symbol: record.symbol,
symbolKo: repairUtf8Mojibake(getKoreanName(market)),
timeframe: record.timeframe,
barCount: record.barCount || 300,
createdAt: record.createdAt ? fmtListTimestamp(record.createdAt) : undefined,
reportKind: 'backtest',
};
}
export function buildLiveReportModel(
item: LiveExecutionItem,
signals: BacktestSignal[],
timeframe: string,
initialCapital = 10_000_000,
): BacktestAnalysisReportModel {
const market = toUpbitMarket(item.symbol);
return {
analysis: buildAnalysisFromLive(item, initialCapital),
signals,
strategyName: repairUtf8Mojibake(item.strategyLabel),
symbol: item.symbol,
symbolKo: getKoreanName(market),
timeframe,
barCount: 300,
createdAt: item.createdAt ? fmtListTimestamp(item.createdAt) : undefined,
reportKind: 'live',
};
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 레포트 HTML을 별도 iframe 문서로 열어 인쇄·PDF 저장 (메인 앱 visibility 인쇄 버그 회피)
*/
/** App.css 전체 로드 전에도 PDF 다페이지·하단 잘림 방지 */
const CRITICAL_PRINT_CSS = `
@media print {
html, body {
height: auto !important;
overflow: visible !important;
margin: 0 !important;
padding: 0 !important;
}
.btd-report-print-doc,
.btd-report-print-area,
.brd-dashboard,
.brd-dashboard--report {
overflow: visible !important;
max-height: none !important;
min-height: 0 !important;
height: auto !important;
font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif !important;
}
.brd-dashboard {
break-inside: auto !important;
page-break-inside: auto !important;
}
.brd-row,
.brd-row--2col,
.brd-row--3col,
.brd-card,
.brd-card--full {
break-inside: auto !important;
page-break-inside: auto !important;
overflow: visible !important;
}
.brd-kpi-grid {
break-inside: auto !important;
page-break-inside: auto !important;
}
.brd-kpi-card,
.brd-dash-header {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
.brd-sig-scroll {
max-height: none !important;
overflow: visible !important;
height: auto !important;
}
.brd-expand-btn {
display: none !important;
}
.btd-report-print-footer {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
}
@page {
size: A4 portrait;
margin: 10mm;
}
`;
const FONT_LINKS = `
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
`;
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function collectDocumentStyles(): string {
const links = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))
.filter(l => l.href && !l.href.includes('fonts.googleapis.com'))
.map(l => `<link rel="stylesheet" href="${l.href}">`);
const inline = Array.from(document.querySelectorAll('style'))
.map(s => s.outerHTML);
return [...links, ...inline].join('\n');
}
export function printReportDocument(contentHtml: string, documentTitle: string): void {
const iframe = document.createElement('iframe');
iframe.setAttribute('title', 'print');
iframe.style.cssText = 'position:fixed;width:0;height:0;border:0;left:-9999px;top:0;';
document.body.appendChild(iframe);
const win = iframe.contentWindow;
const doc = win?.document;
if (!doc) {
iframe.remove();
return;
}
doc.open();
doc.write(`<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>${escapeHtml(documentTitle)}</title>
${FONT_LINKS}
${collectDocumentStyles()}
<style>${CRITICAL_PRINT_CSS}</style>
</head>
<body class="btd-report-print-doc"></body>
</html>`);
doc.close();
if (doc.body) {
doc.body.insertAdjacentHTML('beforeend', contentHtml);
}
let printed = false;
const runPrint = () => {
if (printed) return;
printed = true;
try {
win?.focus();
win?.print();
} finally {
window.setTimeout(() => iframe.remove(), 2000);
}
};
iframe.addEventListener('load', () => {
window.setTimeout(runPrint, 500);
}, { once: true });
window.setTimeout(runPrint, 900);
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 잘못된 인코딩으로 깨진 한글 문자열 복구 (레포트·전략명 등)
* — UTF-8 바이트가 Latin-1 으로 해석된 경우(3ë ¶ → 3분, 1시간 → 1시간)
*/
/** Latin-1 바이트 → UTF-8 재해석 */
function repairFromLatin1Bytes(text: string): string | null {
try {
const bytes = Uint8Array.from(text, c => c.charCodeAt(0) & 0xff);
const repaired = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
if (!repaired || repaired.includes('\uFFFD')) return null;
if (/[\uAC00-\uD7A3]/.test(repaired)) return repaired;
} catch {
/* ignore */
}
return null;
}
/** DB·레거시에서 자주 보이는 깨진 패턴 */
function repairKnownMojibakePatterns(text: string): string {
let out = text;
const rules: [RegExp, string][] = [
[/CC\s+l(?=\s*[0-9])/gi, 'CCI'],
[/1시간/gi, '1시간'],
[/시간/gi, '시간'],
[/([0-9]+)\s*ë\s*¶/gi, '$1분'],
[/ë\s*¶/gi, '분'],
[/시/gi, '시'],
[/ê°„/gi, '간'],
[/é/g, 'é'],
[/Ã /g, ' '],
];
for (const [re, rep] of rules) {
out = out.replace(re, rep);
}
return out;
}
function hasHangul(text: string): boolean {
return /[\uAC00-\uD7A3]/.test(text);
}
function hasMojibakeMarkers(text: string): boolean {
return /[ìíîïðñòóôõö÷øùúûüÿàáâãäåæçèêë]/.test(text)
|| /ë\s*¶/.test(text)
|| /시/.test(text)
|| /ê°„/.test(text)
|| /Ã.|Â./.test(text);
}
function looksLikeBrokenKorean(text: string): boolean {
if (!text) return false;
if (hasHangul(text) && hasMojibakeMarkers(text)) return true;
if (hasMojibakeMarkers(text) && !hasHangul(text)) return true;
return false;
}
function normalizeReportText(text: string): string {
return text
.replace(/,{2,}/g, ', ')
.replace(/\s*,\s*,+/g, ', ')
.replace(/,\s*,/g, ', ')
.replace(/\s{2,}/g, ' ')
.trim();
}
/**
* 깨진 한글·시간봉 표기 복구. 이미 정상 UTF-8 이면 그대로 반환.
*/
export function repairUtf8Mojibake(text: string): string {
if (!text) return text;
let result = text;
if (hasMojibakeMarkers(result)) {
result = repairKnownMojibakePatterns(result);
}
if (looksLikeBrokenKorean(text)) {
const fromBytes = repairFromLatin1Bytes(text);
if (fromBytes && hasHangul(fromBytes) && !looksLikeBrokenKorean(fromBytes)) {
result = fromBytes;
}
}
if (hasMojibakeMarkers(result)) {
result = repairKnownMojibakePatterns(result);
}
return normalizeReportText(result);
}
+41
View File
@@ -0,0 +1,41 @@
import type { TradeHistoryRow } from './backtestEquity';
export type TradeHistorySortKey = 'time' | 'side' | 'price' | 'pnlPct';
export type TradeHistorySortDir = 'asc' | 'desc';
export function sortTradeHistoryRows(
rows: readonly TradeHistoryRow[],
key: TradeHistorySortKey,
dir: TradeHistorySortDir,
): TradeHistoryRow[] {
const mul = dir === 'asc' ? 1 : -1;
return [...rows].sort((a, b) => {
let cmp = 0;
switch (key) {
case 'time':
cmp = a.time - b.time;
break;
case 'side':
cmp = a.side.localeCompare(b.side);
break;
case 'price':
cmp = a.price - b.price;
break;
case 'pnlPct':
cmp = compareNullableNumber(a.pnlPct, b.pnlPct);
break;
default:
cmp = 0;
}
if (cmp !== 0) return cmp * mul;
return a.id - b.id;
});
}
/** 손익 없음(매수)은 항상 목록 끝 */
function compareNullableNumber(a: number | undefined, b: number | undefined): number {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a - b;
}