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