백테스팅 분석결과 레포트 출력
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
type BacktestResultRecord,
|
type BacktestResultRecord,
|
||||||
type BacktestAnalysis,
|
type BacktestAnalysis,
|
||||||
type BacktestSignal,
|
type BacktestSignal,
|
||||||
|
type StrategyDto,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
import type { Theme } from '../types';
|
import type { Theme } from '../types';
|
||||||
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
||||||
@@ -25,6 +26,11 @@ import BacktestExecutionList, { type ExecutionListTab } from './backtest/Backtes
|
|||||||
import BacktestAnalysisChart from './backtest/BacktestAnalysisChart';
|
import BacktestAnalysisChart from './backtest/BacktestAnalysisChart';
|
||||||
import BacktestKpiPanel from './backtest/BacktestKpiPanel';
|
import BacktestKpiPanel from './backtest/BacktestKpiPanel';
|
||||||
import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard';
|
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 { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
|
||||||
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
|
import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +40,7 @@ import {
|
|||||||
} from './strategyEditor/usePanelResize';
|
} from './strategyEditor/usePanelResize';
|
||||||
import '../styles/strategyEditorTheme.css';
|
import '../styles/strategyEditorTheme.css';
|
||||||
import { getKoreanName } from '../utils/marketNameCache';
|
import { getKoreanName } from '../utils/marketNameCache';
|
||||||
|
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||||||
|
|
||||||
const LEFT_KEY = 'btd-left-width';
|
const LEFT_KEY = 'btd-left-width';
|
||||||
const RIGHT_KEY = 'btd-right-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 [selectedBacktest, setSelectedBacktest] = useState<BacktestResultRecord | null>(null);
|
||||||
const [selectedLive, setSelectedLive] = useState<LiveExecutionItem | null>(null);
|
const [selectedLive, setSelectedLive] = useState<LiveExecutionItem | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||||||
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_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 strategyNames = Object.fromEntries(strategies.map(s => [s.id, s.name]));
|
||||||
const live = buildLiveExecutionItems(trades, summary, strategyNames);
|
const live = buildLiveExecutionItems(trades, summary, strategyNames);
|
||||||
|
setStrategies(strategies);
|
||||||
setRecords(list);
|
setRecords(list);
|
||||||
setLiveItems(live);
|
setLiveItems(live);
|
||||||
setSelectedBacktest(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
|
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 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'
|
const showRightDetail = tab === 'backtest'
|
||||||
? backtestDetail?.analysis != null
|
? backtestDetail?.analysis != null
|
||||||
: selectedLive != null;
|
: selectedLive != null;
|
||||||
@@ -305,6 +341,8 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
signals={activeSignals}
|
signals={activeSignals}
|
||||||
strategyId={chartProps.strategyId}
|
strategyId={chartProps.strategyId}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
onOpenReport={() => setReportOpen(true)}
|
||||||
|
reportDisabled={!reportModel}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="btd-empty">
|
<div className="btd-empty">
|
||||||
@@ -347,6 +385,12 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BacktestAnalysisReportModal
|
||||||
|
open={reportOpen}
|
||||||
|
onClose={() => setReportOpen(false)}
|
||||||
|
model={reportModel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
||||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||||
|
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||||||
|
|
||||||
// ── 포맷 유틸 ────────────────────────────────────────────────────────────────
|
// ── 포맷 유틸 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -60,6 +61,8 @@ export interface BacktestDashboardProps {
|
|||||||
timeframe: string;
|
timeframe: string;
|
||||||
barCount: number;
|
barCount: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
/** 레포트·PDF — 시그널 전체 표시·인쇄용 레이아웃 */
|
||||||
|
reportMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 섹션 제목 ─────────────────────────────────────────────────────────────
|
// ── 섹션 제목 ─────────────────────────────────────────────────────────────
|
||||||
@@ -177,8 +180,9 @@ const SIG_COLOR: Record<string, string> = {
|
|||||||
|
|
||||||
export function BacktestDashboard({
|
export function BacktestDashboard({
|
||||||
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
|
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
|
||||||
|
reportMode = false,
|
||||||
}: BacktestDashboardProps) {
|
}: BacktestDashboardProps) {
|
||||||
const [sigExpanded, setSigExpanded] = useState(false);
|
const [sigExpanded, setSigExpanded] = useState(reportMode);
|
||||||
|
|
||||||
const a = analysis;
|
const a = analysis;
|
||||||
if (!a) {
|
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
|
const periodStr = signals.length >= 2
|
||||||
? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}`
|
? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}`
|
||||||
: `${barCount}봉`;
|
: `${barCount}봉`;
|
||||||
@@ -198,12 +202,12 @@ export function BacktestDashboard({
|
|||||||
const sharpeLevel = a.sharpeRatio >= 2 ? '탁월' : a.sharpeRatio >= 1 ? '우수' : a.sharpeRatio >= 0 ? '보통' : '미흡';
|
const sharpeLevel = a.sharpeRatio >= 2 ? '탁월' : a.sharpeRatio >= 1 ? '우수' : a.sharpeRatio >= 0 ? '보통' : '미흡';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="brd-dashboard">
|
<div className={`brd-dashboard${reportMode ? ' brd-dashboard--report' : ''}`}>
|
||||||
|
|
||||||
{/* ── 대시보드 헤더 ── */}
|
{/* ── 대시보드 헤더 ── */}
|
||||||
<div className="brd-dash-header">
|
<div className="brd-dash-header">
|
||||||
<div className="brd-dash-header-left">
|
<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">
|
<div className="brd-dash-meta">
|
||||||
<span className="brd-dash-badge">{symbol}</span>
|
<span className="brd-dash-badge">{symbol}</span>
|
||||||
<span className="brd-dash-badge">{timeframe}</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>}
|
{createdAt && <span className="brd-dash-badge brd-dash-badge--time">실행 {fmtDateStr(createdAt)}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="brd-dash-header-kpi">
|
<div className={`brd-dash-header-kpi${reportMode ? ' brd-dash-header-kpi--report' : ''}`}>
|
||||||
<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.totalReturnPct)}`}>{pct(a.totalReturnPct)}</div>
|
<div className={`brd-dash-big-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</div>
|
||||||
<div className="brd-dash-big-label">총 수익률</div>
|
<div className="brd-dash-big-label">총 수익률</div>
|
||||||
</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)}`}>
|
<div className={`brd-dash-big-val ${colorCls(a.finalEquity - a.initialCapital)}`}>
|
||||||
{wonFmt(a.finalEquity)}
|
{wonFmt(a.finalEquity)}
|
||||||
</div>
|
</div>
|
||||||
@@ -390,7 +394,7 @@ export function BacktestDashboard({
|
|||||||
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
|
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
|
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
|
||||||
{SIG_LABEL[s.type] ?? s.type}
|
{repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
|
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
|
||||||
@@ -402,7 +406,7 @@ export function BacktestDashboard({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{signals.length > 8 && (
|
{!reportMode && signals.length > 8 && (
|
||||||
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
|
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
|
||||||
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
|
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ interface Props {
|
|||||||
/** 투자관리 차트 탭 — 캔들 오버레이 표시/숨김 (세션만, DB 미저장) */
|
/** 투자관리 차트 탭 — 캔들 오버레이 표시/숨김 (세션만, DB 미저장) */
|
||||||
overlayVisibility?: PaperOverlayVisibility;
|
overlayVisibility?: PaperOverlayVisibility;
|
||||||
onToggleOverlay?: (key: PaperOverlayToggleKey) => void;
|
onToggleOverlay?: (key: PaperOverlayToggleKey) => void;
|
||||||
|
/** 분석 레포트 팝업 (백테스팅 상단 툴바) */
|
||||||
|
onOpenReport?: () => void;
|
||||||
|
reportDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -100,6 +103,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
onHistoryLoadingChange,
|
onHistoryLoadingChange,
|
||||||
overlayVisibility,
|
overlayVisibility,
|
||||||
onToggleOverlay,
|
onToggleOverlay,
|
||||||
|
onOpenReport,
|
||||||
|
reportDisabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||||
const { defaults: appDefaults } = useAppSettings();
|
const { defaults: appDefaults } = useAppSettings();
|
||||||
@@ -316,6 +321,23 @@ const BacktestAnalysisChart: React.FC<Props> = ({
|
|||||||
<span className="btd-analysis-select btd-analysis-select--static">{chartTypeKo}</span>
|
<span className="btd-analysis-select btd-analysis-select--static">{chartTypeKo}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="btd-analysis-toolbar-right">
|
<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>
|
<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 type { TradeHistoryRow } from '../../utils/backtestEquity';
|
||||||
import { pct } from '../../utils/backtestUiUtils';
|
import { pct } from '../../utils/backtestUiUtils';
|
||||||
|
import {
|
||||||
|
sortTradeHistoryRows,
|
||||||
|
type TradeHistorySortDir,
|
||||||
|
type TradeHistorySortKey,
|
||||||
|
} from '../../utils/tradeHistoryRowSort';
|
||||||
|
|
||||||
const fmtDateTime = (ts: number) => {
|
const fmtDateTime = (ts: number) => {
|
||||||
const d = new Date(ts * 1000);
|
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')}`;
|
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 {
|
interface Props {
|
||||||
trades: TradeHistoryRow[];
|
trades: TradeHistoryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BacktestTradeHistoryCard({ trades }: Props) {
|
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 (
|
return (
|
||||||
<section className="rsp-trade-card btd-result-card btd-result-card--trades btd-trades-card">
|
<section className="rsp-trade-card btd-result-card btd-result-card--trades btd-trades-card">
|
||||||
<h3 className="rsp-trade-card-title">
|
<h3 className="rsp-trade-card-title">
|
||||||
@@ -25,14 +66,29 @@ export default function BacktestTradeHistoryCard({ trades }: Props) {
|
|||||||
<table className="btd-table btd-table--compact">
|
<table className="btd-table btd-table--compact">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>일시</th>
|
{COLUMNS.map(col => {
|
||||||
<th>유형</th>
|
const active = sortKey === col.key;
|
||||||
<th>체결가</th>
|
return (
|
||||||
<th>손익</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{trades.map(t => (
|
{sortedTrades.map(t => (
|
||||||
<tr key={t.id}>
|
<tr key={t.id}>
|
||||||
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
|
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
|
||||||
<td className={t.side === 'buy' ? 'buy' : 'sell'}>{t.side === 'buy' ? '매수' : '매도'}</td>
|
<td className={t.side === 'buy' ? 'buy' : 'sell'}>{t.side === 'buy' ? '매수' : '매도'}</td>
|
||||||
|
|||||||
@@ -419,6 +419,35 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.btd-table td {
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
border-bottom: 1px solid var(--sep);
|
border-bottom: 1px solid var(--sep);
|
||||||
@@ -1246,6 +1275,8 @@
|
|||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btd-analysis-main {
|
.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