diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index 1737a4d..aaa0bd1 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -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(null); const [selectedLive, setSelectedLive] = useState(null); const [loading, setLoading] = useState(true); + const [reportOpen, setReportOpen] = useState(false); + const [strategies, setStrategies] = useState([]); 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} /> ) : (
@@ -347,6 +385,12 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
+ + setReportOpen(false)} + model={reportModel} + /> ); } diff --git a/frontend/src/components/BacktestResultModal.tsx b/frontend/src/components/BacktestResultModal.tsx index 1bc9762..3767d3d 100644 --- a/frontend/src/components/BacktestResultModal.tsx +++ b/frontend/src/components/BacktestResultModal.tsx @@ -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 = { 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 ( -
+
{/* ── 대시보드 헤더 ── */}
-
{strategyName}
+
{repairUtf8Mojibake(strategyName)}
{symbol} {timeframe} @@ -211,12 +215,12 @@ export function BacktestDashboard({ {createdAt && 실행 {fmtDateStr(createdAt)}}
-
-
+
+
{pct(a.totalReturnPct)}
총 수익률
-
+
{wonFmt(a.finalEquity)}
@@ -390,7 +394,7 @@ export function BacktestDashboard({ {fmtDate(s.time)} - {SIG_LABEL[s.type] ?? s.type} + {repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)} @@ -402,7 +406,7 @@ export function BacktestDashboard({
- {signals.length > 8 && ( + {!reportMode && signals.length > 8 && ( diff --git a/frontend/src/components/backtest/BacktestAnalysisChart.tsx b/frontend/src/components/backtest/BacktestAnalysisChart.tsx index 4bf2cd8..2ad9d77 100644 --- a/frontend/src/components/backtest/BacktestAnalysisChart.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisChart.tsx @@ -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 = ({ onHistoryLoadingChange, overlayVisibility, onToggleOverlay, + onOpenReport, + reportDisabled = false, }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); const { defaults: appDefaults } = useAppSettings(); @@ -316,6 +321,23 @@ const BacktestAnalysisChart: React.FC = ({ {chartTypeKo}
+ {onOpenReport && ( + + )} diff --git a/frontend/src/components/backtest/BacktestAnalysisReportModal.tsx b/frontend/src/components/backtest/BacktestAnalysisReportModal.tsx new file mode 100644 index 0000000..7ee5de6 --- /dev/null +++ b/frontend/src/components/backtest/BacktestAnalysisReportModal.tsx @@ -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 = () => ( + + + + + + + +); + +const IcPrint = () => ( + + + + + +); + +export default function BacktestAnalysisReportModal({ open, onClose, model }: Props) { + const printAreaRef = useRef(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 = ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+
+ +
+

투자분석 상세 레포트

+

+ {model.reportKind === 'live' ? '실시간 매매' : '백테스팅'} + {model.symbolKo ? ` · ${model.symbolKo}` : ''} +

+
+
+
+
+ +

PDF 저장 시 대상에서 「PDF로 저장」 선택 · 색상 유지는 「배경 그래픽」 체크

+
+ +
+
+ +
+
+
+
+

GoldenChart 투자분석 레포트

+

+ 출력일시 {printedAt} + {model.createdAt ? ` · 분석 실행 ${model.createdAt}` : ''} +

+
+ {model.symbolKo && ( +

{model.symbolKo}

+ )} +
+ +
+

본 레포트는 GoldenChart 백테스팅·투자분석 결과이며 투자 권유가 아닙니다.

+
+
+
+
+
+ ); + + return createPortal(content, document.body); +} diff --git a/frontend/src/components/backtest/BacktestTradeHistoryCard.tsx b/frontend/src/components/backtest/BacktestTradeHistoryCard.tsx index cfb4f96..10a109a 100644 --- a/frontend/src/components/backtest/BacktestTradeHistoryCard.tsx +++ b/frontend/src/components/backtest/BacktestTradeHistoryCard.tsx @@ -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('time'); + const [sortDir, setSortDir] = useState('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 (

@@ -25,14 +66,29 @@ export default function BacktestTradeHistoryCard({ trades }: Props) { - - - - + {COLUMNS.map(col => { + const active = sortKey === col.key; + return ( + + ); + })} - {trades.map(t => ( + {sortedTrades.map(t => ( diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index e498f2c..10dff67 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -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 { diff --git a/frontend/src/styles/backtestReportPrint.css b/frontend/src/styles/backtestReportPrint.css new file mode 100644 index 0000000..df5580b --- /dev/null +++ b/frontend/src/styles/backtestReportPrint.css @@ -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; + } +} diff --git a/frontend/src/utils/backtestReportModel.ts b/frontend/src/utils/backtestReportModel.ts new file mode 100644 index 0000000..b26a2a7 --- /dev/null +++ b/frontend/src/utils/backtestReportModel.ts @@ -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 = {}, +): 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', + }; +} diff --git a/frontend/src/utils/printReportDocument.ts b/frontend/src/utils/printReportDocument.ts new file mode 100644 index 0000000..93b1ac9 --- /dev/null +++ b/frontend/src/utils/printReportDocument.ts @@ -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 = ` + + + +`; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function collectDocumentStyles(): string { + const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')) + .filter(l => l.href && !l.href.includes('fonts.googleapis.com')) + .map(l => ``); + 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(` + + + + +${escapeHtml(documentTitle)} +${FONT_LINKS} +${collectDocumentStyles()} + + + +`); + 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); +} diff --git a/frontend/src/utils/textEncoding.ts b/frontend/src/utils/textEncoding.ts new file mode 100644 index 0000000..157865b --- /dev/null +++ b/frontend/src/utils/textEncoding.ts @@ -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); +} diff --git a/frontend/src/utils/tradeHistoryRowSort.ts b/frontend/src/utils/tradeHistoryRowSort.ts new file mode 100644 index 0000000..e2891cf --- /dev/null +++ b/frontend/src/utils/tradeHistoryRowSort.ts @@ -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; +}
일시유형체결가손익 + +
{fmtDateTime(t.time)} {t.side === 'buy' ? '매수' : '매도'}