백테스팅 분석결과 레포트 출력
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user