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

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
+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;
}