분석 레포트 화면 수정

This commit is contained in:
Macbook
2026-06-07 17:43:22 +09:00
parent 7ec754770d
commit 5928599fd0
11 changed files with 1802 additions and 187 deletions
@@ -4,7 +4,7 @@
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
*/
import React, { useState } from 'react';
import React from 'react';
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { repairUtf8Mojibake } from '../utils/textEncoding';
@@ -21,6 +21,7 @@ import {
CompareBar,
} from './shared/analysisDashboardUi';
import { analysisMethodLabel } from '../utils/analysisMethodLabels';
import BacktestSignalTable from './backtest/BacktestSignalTable';
const fmtDate = (ts: number) => {
const d = new Date(ts * 1000);
@@ -55,17 +56,12 @@ export interface BacktestDashboardProps {
createdAt?: string;
/** 레포트·PDF — 시그널 전체 표시·인쇄용 레이아웃 */
reportMode?: boolean;
/** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */
hideSignals?: boolean;
}
// ── 시그널 타입 ───────────────────────────────────────────────────────────
const SIG_LABEL: Record<string, string> = {
BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도',
};
const SIG_COLOR: Record<string, string> = {
BUY:'brd-sig-buy', SELL:'brd-sig-sell', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
};
// ══════════════════════════════════════════════════════════════════════════
// BacktestDashboard — 메인 대시보드 컴포넌트 (재사용 가능)
// ══════════════════════════════════════════════════════════════════════════
@@ -73,9 +69,8 @@ const SIG_COLOR: Record<string, string> = {
export function BacktestDashboard({
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
reportMode = false,
hideSignals = false,
}: BacktestDashboardProps) {
const [sigExpanded, setSigExpanded] = useState(reportMode);
const a = analysis;
if (!a) {
return (
@@ -86,7 +81,6 @@ export function BacktestDashboard({
);
}
const sigLimit = reportMode || sigExpanded ? signals.length : 8;
const periodStr = signals.length >= 2
? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}`
: `${barCount}`;
@@ -276,54 +270,11 @@ export function BacktestDashboard({
</div>
{/* ── Row 4: 거래 시그널 목록 ── */}
<div className="brd-row">
<div className="brd-card brd-card--full">
<SectionTitle icon="📋" title="거래 시그널" right={
<span className="brd-section-count">{signals.length}</span>
}/>
{signals.length === 0 ? (
<div className="brd-empty-msg"> .</div>
) : (
<>
<div className="brd-sig-scroll">
<table className="brd-sig-table">
<thead>
<tr>
<th style={{width:36}}>#</th>
<th></th>
<th></th>
<th style={{textAlign:'right'}}></th>
<th style={{textAlign:'center',width:50}}>#</th>
</tr>
</thead>
<tbody>
{signals.slice(0, sigLimit).map((s, i) => (
<tr key={i} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
<td style={{textAlign:'center',color:'var(--text3)'}}>{i+1}</td>
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
<td>
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
{repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)}
</span>
</td>
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
{Math.round(s.price).toLocaleString()}
</td>
<td style={{textAlign:'center',color:'var(--text3)'}}>{s.barIndex}</td>
</tr>
))}
</tbody>
</table>
</div>
{!reportMode && signals.length > 8 && (
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
</button>
)}
</>
)}
{!hideSignals && (
<div className="brd-row">
<BacktestSignalTable signals={signals} expanded={reportMode} />
</div>
</div>
)}
</div>
);
@@ -0,0 +1,43 @@
/**
* 분석레포트 — 중앙 패널 (종목·실행 분석 상세)
*/
import React from 'react';
import { BacktestDashboard } from '../BacktestResultModal';
import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal';
interface Props {
model: BacktestAnalysisReportModel | null;
}
export function AnalysisReportCenterPanel({ model }: Props) {
if (!model) {
return (
<div className="arp-center-shell arp-report-theme">
<div className="arp-report-empty">
<span className="arp-report-empty-icon" aria-hidden>📊</span>
<p> .</p>
</div>
</div>
);
}
return (
<div className="arp-center-shell arp-report-theme">
<div className="arp-center-inner">
<BacktestDashboard
analysis={model.analysis}
signals={model.signals}
strategyName={model.strategyName}
symbol={model.symbol}
timeframe={model.timeframe}
barCount={model.barCount}
createdAt={model.createdAt}
reportMode
hideSignals
/>
</div>
</div>
);
}
export default AnalysisReportCenterPanel;
@@ -0,0 +1,31 @@
/**
* 분석레포트 — 상단 컨텍스트 바 (중앙 패널 좌측 정렬 · 종목명 + 실행 정보)
*/
import React from 'react';
import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal';
interface Props {
model: BacktestAnalysisReportModel | null;
}
function executionMeta(model: BacktestAnalysisReportModel): string {
const kind = model.reportKind === 'live' ? '실시간 매매' : '백테스팅';
return model.createdAt ? `${kind} · 분석 실행 ${model.createdAt}` : kind;
}
export default function AnalysisReportContextBar({ model }: Props) {
if (!model) return null;
const symbolLabel = model.symbolKo?.trim() || model.symbol;
return (
<div className="arp-context-bar" aria-label="선택 실행 정보">
<div className="arp-context-bar__left-gap" aria-hidden />
<div className="arp-context-bar__center">
<span className="arp-context-symbol">{symbolLabel}</span>
<span className="arp-context-meta">{executionMeta(model)}</span>
</div>
<div className="arp-context-bar__right-gap" aria-hidden />
</div>
);
}
@@ -1,7 +1,7 @@
/**
* 분석레포트 — 좌: 백테스팅/실시간 목록(백테스팅 화면과 동일) · 우: PDF 10 Variations 탭
* 분석레포트 — 가상매매와 동일 3패널 (좌: 목록 · 중: 분석 · 우: 시그널)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { Theme } from '../../types';
import {
loadBacktestResults,
@@ -11,33 +11,25 @@ import {
type BacktestResultRecord,
type StrategyDto,
} from '../../utils/backendApi';
import type { AnalysisReportTypeId } from '../../utils/analysisReportTypes';
import { ANALYSIS_REPORT_TYPES } from '../../utils/analysisReportTypes';
import {
buildContextFromBacktest,
buildContextFromLive,
} from '../../utils/analysisReportContext';
import { buildLiveExecutionItems, type LiveExecutionItem } from '../../utils/liveExecutionGroups';
import { PAPER_TRADES_CHANGED_EVENT } from '../../utils/paperTradeEvents';
import {
readStoredSize,
storeSize,
usePanelResize,
} from '../strategyEditor/usePanelResize';
import BacktestExecutionList, { type ExecutionListTab } from '../backtest/BacktestExecutionList';
import AnalysisReportViews from './AnalysisReportViews';
import BuilderPageShell from '../layout/BuilderPageShell';
import AnalysisReportCenterPanel from './AnalysisReportCenterPanel';
import AnalysisReportSignalsPanel from './AnalysisReportSignalsPanel';
import AnalysisReportContextBar from './AnalysisReportContextBar';
import BacktestAnalysisReportModal from '../backtest/BacktestAnalysisReportModal';
import {
buildBacktestReportModel,
buildLiveReportModel,
} from '../../utils/backtestReportModel';
import '../../styles/strategyEditorTheme.css';
import '../../styles/backtestDashboard.css';
import '../../styles/analysisReportPage.css';
const LEFT_KEY = 'arp-left-width';
const LEFT_DEFAULT = 380;
interface Props {
theme?: Theme;
}
@@ -52,7 +44,6 @@ function pickCompareBacktest(
}
export function AnalysisReportPage({ theme = 'dark' }: Props) {
const [reportTypeId, setReportTypeId] = useState<AnalysisReportTypeId>('standard');
const [sourceTab, setSourceTab] = useState<ExecutionListTab>('backtest');
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
const [liveItems, setLiveItems] = useState<LiveExecutionItem[]>([]);
@@ -62,11 +53,6 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
const [loading, setLoading] = useState(true);
const [reportOpen, setReportOpen] = useState(false);
const [leftWidth, setLeftWidth] = useState(() => Math.max(readStoredSize(LEFT_KEY, LEFT_DEFAULT), 300));
const leftRef = useRef(leftWidth);
leftRef.current = leftWidth;
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v));
const refreshLive = useCallback(async () => {
const [trades, summary, stratList] = await Promise.all([
loadPaperTrades(),
@@ -108,11 +94,6 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
return pickCompareBacktest(selectedBacktest, records);
}, [records, selectedBacktest]);
const compareLive = useMemo(() => {
if (!selectedLive) return null;
return liveItems.find(x => x.id !== selectedLive.id) ?? null;
}, [liveItems, selectedLive]);
const ctx = useMemo(() => {
if (sourceTab === 'backtest' && selectedBacktest) {
return buildContextFromBacktest(selectedBacktest, compareBacktest);
@@ -143,42 +124,40 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
return null;
}, [sourceTab, selectedBacktest, selectedLive, ctx, strategyNamesById]);
if (loading) {
return (
<div className={`arp-page se-page se-page--${theme}`}>
<header className="btd-header">
<h1 className="btd-header-title"></h1>
</header>
<div className="btd-loading"> </div>
</div>
);
}
const headerActions = (
<>
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}></button>
<button
type="button"
className="btd-btn btd-btn--gold"
disabled={!reportModel}
onClick={() => setReportOpen(true)}
>
/ PDF
</button>
</>
);
return (
<div className={`arp-page se-page se-page--${theme}`}>
<header className="btd-header">
<div className="btd-header-brand">
<h1 className="btd-header-title"></h1>
<span className="btd-header-sub">Golden Analysis Command Center</span>
</div>
<div className="btd-header-actions">
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}></button>
<button
type="button"
className="btd-btn btd-btn--gold"
disabled={!reportModel}
onClick={() => setReportOpen(true)}
>
/ PDF
</button>
</div>
</header>
<div className="arp-body">
<aside
className="arp-sidebar btd-sidebar"
style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}
>
<>
<BuilderPageShell
theme={theme}
title="분석레포트"
subtitle="Golden Analysis Command Center"
pageClassName="bps-page--arp"
loading={loading}
loadingText="분석레포트 로딩…"
collapsiblePanels
leftStorageKey="arp-left-width"
leftDefaultWidth={380}
leftCollapsedStorageKey="arp-left-open"
rightStorageKey="arp-right-width"
rightDefaultWidth={320}
rightCollapsedStorageKey="arp-right-open"
leftTitle="실행 목록"
headerActions={headerActions}
banner={<AnalysisReportContextBar model={reportModel} />}
left={(
<BacktestExecutionList
tab={sourceTab}
onTabChange={setSourceTab}
@@ -195,67 +174,17 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
setSourceTab('live');
}}
/>
</aside>
<div
className="arp-splitter btd-splitter btd-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="좌측 패널 너비"
onPointerDown={onLeftSplit}
/>
<main className="arp-main">
<div className="arp-type-tabs" role="tablist" aria-label="레포트 유형">
{ANALYSIS_REPORT_TYPES.map(t => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={reportTypeId === t.id}
className={`arp-type-tab${reportTypeId === t.id ? ' arp-type-tab--on' : ''}`}
title={t.subtitle}
onClick={() => setReportTypeId(t.id)}
>
<span className="arp-type-tab-num">{t.tabLabel.split(' ')[0]}</span>
<span className="arp-type-tab-name">{t.tabLabel.replace(/^\d+\s*/, '')}</span>
</button>
))}
<div className="arp-type-tabs-end">
<button
type="button"
className="arp-tab-report-btn"
disabled={!reportModel}
title="상세분석 레포트"
onClick={() => setReportOpen(true)}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="9" y1="21" x2="9" y2="9"/>
</svg>
</button>
</div>
</div>
<div className="arp-viewport">
<AnalysisReportViews
typeId={reportTypeId}
ctx={ctx}
theme={theme}
allBacktests={records}
compareBacktest={compareBacktest}
compareLive={compareLive}
/>
</div>
</main>
</div>
)}
center={<AnalysisReportCenterPanel model={reportModel} />}
right={<AnalysisReportSignalsPanel model={reportModel} />}
/>
<BacktestAnalysisReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
model={reportModel}
/>
</div>
</>
);
}
@@ -0,0 +1,31 @@
/**
* 분석레포트 — 우측 패널 (거래 시그널 목록)
*/
import React from 'react';
import BacktestSignalTable from '../backtest/BacktestSignalTable';
import type { BacktestAnalysisReportModel } from '../backtest/BacktestAnalysisReportModal';
interface Props {
model: BacktestAnalysisReportModel | null;
}
export function AnalysisReportSignalsPanel({ model }: Props) {
if (!model) {
return (
<div className="arp-signals-empty">
<p> .</p>
</div>
);
}
return (
<div className="arp-report-theme arp-signals-panel">
<BacktestSignalTable signals={model.signals} expanded className="brd-card--sig-fill" />
<footer className="arp-report-print-footer arp-report-print-footer--signals">
<p> GoldenChart · .</p>
</footer>
</div>
);
}
export default AnalysisReportSignalsPanel;
@@ -0,0 +1,173 @@
import React, { memo, useMemo } from 'react';
import type { EquityPoint } from '../../utils/backtestEquity';
import { drawdownSeries } from '../../utils/analysisReportContext';
interface Props {
curve: EquityPoint[];
initialCapital?: number;
}
function fmtDate(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
}
function fmtWon(v: number): string {
if (v >= 1e8) return `${(v / 1e8).toFixed(1)}`;
if (v >= 1e4) return `${Math.round(v / 1e4)}`;
return Math.round(v).toLocaleString();
}
/** 자산 곡선(상) + 낙폭 Underwater(하) — 동일 X축 */
const EquityDrawdownChart: React.FC<Props> = memo(({ curve, initialCapital }) => {
const W = 900;
const H = 320;
const pad = { l: 52, r: 16, t: 14, b: 24 };
const splitY = pad.t + (H - pad.t - pad.b) * 0.58;
const eqH = splitY - pad.t - 8;
const ddH = H - pad.b - splitY - 8;
const chart = useMemo(() => {
if (curve.length < 2) return null;
const times = curve.map(p => p.time);
const equities = curve.map(p => p.equity);
const dd = drawdownSeries(curve);
const ddPcts = dd.map(d => d.ddPct * 100);
const tMin = Math.min(...times);
const tMax = Math.max(...times);
const tRange = tMax - tMin || 1;
const eMin = Math.min(...equities) * 0.99;
const eMax = Math.max(...equities) * 1.01;
const eRange = eMax - eMin || 1;
const ddMin = Math.min(...ddPcts, -0.5);
const ddMax = 0.5;
const ddRange = ddMax - ddMin || 1;
const toX = (t: number) => pad.l + ((t - tMin) / tRange) * (W - pad.l - pad.r);
const toEqY = (e: number) => pad.t + eqH - ((e - eMin) / eRange) * eqH;
const toDdY = (pct: number) => splitY + 8 + ddH - ((pct - ddMin) / ddRange) * ddH;
const eqPath = curve.map((p, i) =>
`${i === 0 ? 'M' : 'L'}${toX(p.time).toFixed(1)},${toEqY(p.equity).toFixed(1)}`,
).join(' ');
const eqArea = `${eqPath} L${toX(curve[curve.length - 1].time).toFixed(1)},${splitY} L${toX(curve[0].time).toFixed(1)},${splitY} Z`;
const ddPath = dd.map((d, i) =>
`${i === 0 ? 'M' : 'L'}${toX(d.time).toFixed(1)},${toDdY(d.ddPct * 100).toFixed(1)}`,
).join(' ');
const ddArea = `${ddPath} L${toX(dd[dd.length - 1].time).toFixed(1)},${splitY + 8 + ddH} L${toX(dd[0].time).toFixed(1)},${splitY + 8 + ddH} Z`;
let mddPct = 0;
let mddTime = curve[0].time;
for (const d of dd) {
if (d.ddPct < mddPct) {
mddPct = d.ddPct;
mddTime = d.time;
}
}
const mddIdx = dd.findIndex(d => d.time === mddTime);
const mddPoint = mddIdx >= 0 ? dd[mddIdx] : dd[0];
const xStep = Math.max(1, Math.floor(curve.length / 6));
const xLabels: { x: number; label: string }[] = [];
for (let i = 0; i < curve.length; i += xStep) {
xLabels.push({ x: toX(curve[i].time), label: fmtDate(curve[i].time) });
}
const eqTicks = [0, 0.25, 0.5, 0.75, 1].map(r => eMin + eRange * r);
const ddTicks = [0, ddMin * 0.5, ddMin].filter((v, i, a) => a.indexOf(v) === i);
return {
eqPath, eqArea, ddPath, ddArea,
toX, toEqY, toDdY,
xLabels, eqTicks, ddTicks,
mddPct, mddX: toX(mddPoint.time), mddY: toDdY(mddPoint.ddPct * 100),
finalEquity: curve[curve.length - 1].equity,
};
}, [curve, eqH, ddH, splitY]);
if (!chart || curve.length < 2) {
return (
<div className="qpd-chart-empty"> .</div>
);
}
const init = initialCapital ?? curve[0]?.equity ?? 0;
const retPct = init > 0 ? ((chart.finalEquity - init) / init) * 100 : 0;
return (
<div className="qpd-equity-dd-wrap">
<svg className="qpd-equity-dd-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="qpd-eq-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(34,197,94,0.35)" />
<stop offset="100%" stopColor="rgba(34,197,94,0.02)" />
</linearGradient>
<linearGradient id="qpd-dd-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(239,68,68,0.02)" />
<stop offset="100%" stopColor="rgba(239,68,68,0.35)" />
</linearGradient>
</defs>
{/* 구분선 */}
<line x1={pad.l} y1={splitY} x2={W - pad.r} y2={splitY}
stroke="rgba(255,255,255,0.12)" strokeDasharray="4 3" />
{/* Equity 영역 */}
<text x={pad.l} y={pad.t - 2} className="qpd-chart-axis-title" fill="#94a3b8"> (Equity)</text>
{chart.eqTicks.map(v => (
<g key={`eq-${v}`}>
<line x1={pad.l} y1={chart.toEqY(v)} x2={W - pad.r} y2={chart.toEqY(v)}
stroke="rgba(255,255,255,0.06)" />
<text x={pad.l - 6} y={chart.toEqY(v) + 3} textAnchor="end"
className="qpd-chart-tick" fill="#64748b">{fmtWon(v)}</text>
</g>
))}
<path d={chart.eqArea} fill="url(#qpd-eq-fill)" />
<path d={chart.eqPath} fill="none" stroke="#22c55e" strokeWidth="2.2" strokeLinejoin="round" />
{/* Drawdown 영역 */}
<text x={pad.l} y={splitY + 14} className="qpd-chart-axis-title" fill="#94a3b8"> (Drawdown %)</text>
<line x1={pad.l} y1={splitY + 8} x2={W - pad.r} y2={splitY + 8}
stroke="rgba(255,255,255,0.15)" />
{chart.ddTicks.map(v => (
<g key={`dd-${v}`}>
<text x={pad.l - 6} y={chart.toDdY(v) + 3} textAnchor="end"
className="qpd-chart-tick" fill="#64748b">{v.toFixed(1)}%</text>
</g>
))}
<path d={chart.ddArea} fill="url(#qpd-dd-fill)" />
<path d={chart.ddPath} fill="none" stroke="#ef4444" strokeWidth="1.8" strokeLinejoin="round" />
{/* MDD 마커 */}
<circle cx={chart.mddX} cy={chart.mddY} r="4" fill="#ef4444" stroke="#fff" strokeWidth="1" />
<text x={chart.mddX} y={chart.mddY - 8} textAnchor="middle"
className="qpd-chart-annotation" fill="#ef4444">
MDD {(chart.mddPct * 100).toFixed(1)}%
</text>
{/* X축 */}
{chart.xLabels.map((l, i) => (
<text key={i} x={l.x} y={H - 6} textAnchor="middle" className="qpd-chart-tick" fill="#64748b">
{l.label}
</text>
))}
</svg>
<div className="qpd-equity-dd-legend">
<span className="qpd-leg qpd-leg--eq"> </span>
<span className="qpd-leg qpd-leg--dd">Underwater </span>
<span className={`qpd-leg qpd-leg--ret ${retPct >= 0 ? 'up' : 'down'}`}>
{retPct >= 0 ? '+' : ''}{retPct.toFixed(2)}%
</span>
</div>
</div>
);
});
EquityDrawdownChart.displayName = 'EquityDrawdownChart';
export default EquityDrawdownChart;
@@ -0,0 +1,352 @@
/**
* 분석레포트 — 우측 상세 (5대 KPI + 3종 필수 그래프)
*/
import React, { useMemo, useState } from 'react';
import type { Theme } from '../../types';
import type { AnalysisReportContext } from '../../utils/analysisReportContext';
import { buildHoldingPoints } from '../../utils/analysisReportMetrics';
import { repairUtf8Mojibake } from '../../utils/textEncoding';
import { formatUpbitKrwPrice } from '../../utils/safeFormat';
import { pctAbs } from '../../utils/backtestUiUtils';
import EquityDrawdownChart from './EquityDrawdownChart';
import ReturnsHistogramChart from './ReturnsHistogramChart';
import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart';
type DetailTab = 'overview' | 'trades' | 'log';
interface Props {
ctx: AnalysisReportContext | null;
theme: Theme;
}
type BenchStatus = 'good' | 'warn' | 'bad' | 'neutral';
function benchStatus(
value: number,
good: (v: number) => boolean,
warn: (v: number) => boolean,
): BenchStatus {
if (good(value)) return 'good';
if (warn(value)) return 'warn';
return 'bad';
}
function fmtDuration(sec: number): string {
if (sec <= 0) return '—';
if (sec < 3600) return `${Math.round(sec / 60)}`;
if (sec < 86400) {
const h = Math.floor(sec / 3600);
const m = Math.round((sec % 3600) / 60);
return m > 0 ? `${h}시간 ${m}` : `${h}시간`;
}
const d = Math.floor(sec / 86400);
const h = Math.round((sec % 86400) / 3600);
return h > 0 ? `${d}${h}시간` : `${d}`;
}
function 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')}`;
}
function KpiCard({
label,
value,
benchmark,
status,
hint,
}: {
label: string;
value: string;
benchmark: string;
status: BenchStatus;
hint: string;
}) {
return (
<article className={`qpd-kpi qpd-kpi--${status}`}>
<div className="qpd-kpi-head">
<span className="qpd-kpi-label">{label}</span>
<span className={`qpd-kpi-badge qpd-kpi-badge--${status}`}>
{status === 'good' ? '양호' : status === 'warn' ? '보통' : status === 'bad' ? '주의' : '—'}
</span>
</div>
<div className="qpd-kpi-value">{value}</div>
<div className="qpd-kpi-bench">: {benchmark}</div>
<p className="qpd-kpi-hint">{hint}</p>
</article>
);
}
function TradeLogTable({ ctx }: { ctx: AnalysisReportContext }) {
const rows = useMemo(() => {
const buys = new Map<string, { time: number; price: number; qty: number }>();
const out: {
id: number;
time: string;
side: string;
entry: number;
exit: number;
qty: number;
pnlPct: number;
duration: string;
}[] = [];
const sorted = [...ctx.trades].sort((a, b) => a.time - b.time);
for (const t of sorted) {
if (t.side === 'buy') {
buys.set(t.symbol, { time: t.time, price: t.price, qty: t.quantity });
} else {
const buy = buys.get(t.symbol);
const dur = buy ? t.time - buy.time : 0;
out.push({
id: t.id,
time: fmtDateTime(t.time),
side: '매수→매도',
entry: buy?.price ?? t.price,
exit: t.price,
qty: t.quantity,
pnlPct: (t.pnlPct ?? 0) * 100,
duration: fmtDuration(dur),
});
buys.delete(t.symbol);
}
}
return out;
}, [ctx.trades]);
return (
<div className="qpd-log-wrap">
<table className="qpd-log-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={7} className="qpd-log-empty"> </td></tr>
) : rows.map(r => (
<tr key={r.id} className={r.pnlPct >= 0 ? 'qpd-row--up' : 'qpd-row--down'}>
<td>{r.time}</td>
<td>{r.side}</td>
<td>{formatUpbitKrwPrice(r.entry)}</td>
<td>{formatUpbitKrwPrice(r.exit)}</td>
<td>{r.qty.toFixed(4)}</td>
<td className={r.pnlPct >= 0 ? 'up' : 'down'}>
{r.pnlPct >= 0 ? '+' : ''}{r.pnlPct.toFixed(2)}%
</td>
<td>{r.duration}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function QuantPerformanceDetail({ ctx, theme }: Props) {
const [tab, setTab] = useState<DetailTab>('overview');
const a = ctx?.analysis;
const holdingPts = useMemo(
() => (ctx ? buildHoldingPoints(ctx.signals, ctx.symbol) : []),
[ctx],
);
const avgHoldSec = useMemo(() => {
if (holdingPts.length === 0) return 0;
return (holdingPts.reduce((s, p) => s + p.durationMin, 0) / holdingPts.length) * 60;
}, [holdingPts]);
const chartRange = useMemo(() => {
if (!ctx || ctx.signals.length === 0) return null;
const times = ctx.signals.map(s => s.time);
const tMax = Math.max(...times);
const barSec = 3600;
const barCount = ctx.barCount || 300;
return {
symbol: ctx.symbol,
timeframe: ctx.timeframe,
toTimeSec: tMax + barSec,
fromTimeSec: tMax - barSec * barCount,
barCount,
signals: ctx.signals,
strategyId: ctx.backtestRecord?.strategyId ?? ctx.liveItem?.trades.find(t => t.strategyId)?.strategyId ?? null,
};
}, [ctx]);
if (!ctx) {
return (
<div className="qpd-empty">
<span className="qpd-empty-icon">📊</span>
<p> .</p>
</div>
);
}
const winRate = a?.winRate ?? 0;
const pf = a?.profitLossRatio ?? 0;
const mdd = a?.maxDrawdownPct ?? 0;
const sharpe = a?.sharpeRatio ?? 0;
const totalRet = a?.totalReturnPct ?? 0;
const tradeCount = a?.numberOfPositions ?? ctx.trades.filter(t => t.side === 'sell').length;
const winStatus = benchStatus(winRate, v => v >= 0.5, v => v >= 0.4);
const pfStatus = benchStatus(pf, v => v >= 2, v => v >= 1.5);
const mddStatus = benchStatus(Math.abs(mdd), v => v <= 0.1, v => v <= 0.15);
const sharpeStatus = benchStatus(sharpe, v => v >= 2, v => v >= 1);
const holdStatus: BenchStatus = holdingPts.length > 0 ? 'neutral' : 'warn';
return (
<div className="qpd-detail">
<header className="qpd-head">
<div className="qpd-head-main">
<h2 className="qpd-title">{repairUtf8Mojibake(ctx.strategyName)}</h2>
<div className="qpd-meta">
<span className="qpd-badge">{ctx.symbol}</span>
<span className="qpd-badge">{ctx.timeframe}</span>
<span className="qpd-badge qpd-badge--muted">
{ctx.sourceTab === 'backtest' ? '백테스팅' : '모의·실시간'}
</span>
<span className="qpd-badge"> {tradeCount}</span>
<span className={`qpd-badge qpd-badge--ret ${totalRet >= 0 ? 'up' : 'down'}`}>
{totalRet >= 0 ? '+' : ''}{(totalRet * 100).toFixed(2)}%
</span>
</div>
</div>
</header>
<div className="qpd-kpi-row">
<KpiCard
label="승률 (Win Rate)"
value={pctAbs(winRate, 1)}
benchmark="50% 이상 (추세추종 40%+)"
status={winStatus}
hint="이익 청산 비율 — 전략 유형에 따라 기준 상이"
/>
<KpiCard
label="손익비 (Profit Factor)"
value={pf >= 99 ? '99+' : pf.toFixed(2)}
benchmark="1.5+ 양호 · 2.0+ 우수"
status={pfStatus}
hint="총 이익 ÷ 총 손실 — 1 미만이면 구조적 손실"
/>
<KpiCard
label="MDD (최대 낙폭)"
value={`${(mdd * 100).toFixed(2)}%`}
benchmark="-15% 이하 권장"
status={mddStatus}
hint="전고점 대비 최대 하락 — 멘탈·자금 관리 한계 판단"
/>
<KpiCard
label="샤프 지수 (Sharpe)"
value={sharpe.toFixed(2)}
benchmark="1.0+ 양호 · 2.0+ 우수"
status={sharpeStatus}
hint="변동성 대비 위험조정 수익"
/>
<KpiCard
label="평균 보유 기간"
value={fmtDuration(avgHoldSec)}
benchmark="전략 성격(단타/스윙)과 일치"
status={holdStatus}
hint={holdingPts.length > 0
? `${holdingPts.length}건 평균 · ${ctx.timeframe} 전략과 비교`
: '보유 구간 데이터 없음'}
/>
</div>
<nav className="qpd-subtabs" role="tablist" aria-label="상세 분석">
{([
['overview', '① 종합 (자산·분포)'],
['trades', '② 매매 타점 차트'],
['log', '③ 거래 내역'],
] as const).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
className={`qpd-subtab${tab === id ? ' qpd-subtab--on' : ''}`}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</nav>
<div className="qpd-panel" role="tabpanel">
{tab === 'overview' && (
<div className="qpd-overview">
<section className="qpd-section">
<header className="qpd-section-head">
<h3> &amp; (Equity · Drawdown)</h3>
<p>
, Underwater .
MDD가 .
</p>
</header>
<EquityDrawdownChart
curve={ctx.equityCurve}
initialCapital={a?.initialCapital}
/>
</section>
<section className="qpd-section">
<header className="qpd-section-head">
<h3> (Returns Distribution)</h3>
<p>
() .
.
</p>
</header>
<ReturnsHistogramChart trades={ctx.trades} />
</section>
</div>
)}
{tab === 'trades' && chartRange && (
<section className="qpd-section qpd-section--chart">
<header className="qpd-section-head">
<h3> (Trade Plot)</h3>
<p>
()·() ·,
· .
</p>
</header>
<div className="qpd-trade-chart">
<BacktestAnalysisChart
symbol={chartRange.symbol}
timeframe={chartRange.timeframe}
toTimeSec={chartRange.toTimeSec}
fromTimeSec={chartRange.fromTimeSec}
barCount={chartRange.barCount}
signals={chartRange.signals}
strategyId={chartRange.strategyId}
theme={theme}
/>
</div>
</section>
)}
{tab === 'trades' && !chartRange && (
<div className="qpd-chart-empty"> .</div>
)}
{tab === 'log' && (
<section className="qpd-section">
<header className="qpd-section-head">
<h3> </h3>
<p>· , , .</p>
</header>
<TradeLogTable ctx={ctx} />
</section>
)}
</div>
</div>
);
}
@@ -0,0 +1,109 @@
import React, { memo, useMemo } from 'react';
import { buildReturnDistribution } from '../../utils/analysisReportMetrics';
import type { TradeHistoryRow } from '../../utils/backtestEquity';
interface Props {
trades: TradeHistoryRow[];
}
const ReturnsHistogramChart: React.FC<Props> = memo(({ trades }) => {
const buckets = useMemo(() => buildReturnDistribution(trades), [trades]);
const withPnl = useMemo(
() => trades.filter(t => t.pnlPct != null).map(t => (t.pnlPct ?? 0) * 100),
[trades],
);
const stats = useMemo(() => {
if (withPnl.length === 0) return null;
const avg = withPnl.reduce((a, b) => a + b, 0) / withPnl.length;
const wins = withPnl.filter(v => v > 0).length;
const losses = withPnl.filter(v => v < 0).length;
const leftTail = withPnl.filter(v => v < -5).length;
const rightTail = withPnl.filter(v => v > 10).length;
return { avg, wins, losses, leftTail, rightTail, total: withPnl.length };
}, [withPnl]);
const W = 900;
const H = 220;
const pad = { l: 44, r: 20, t: 16, b: 36 };
const innerW = W - pad.l - pad.r;
const innerH = H - pad.t - pad.b;
const maxCount = Math.max(...buckets.map(b => b.count), 1);
const barW = buckets.length > 0 ? innerW / buckets.length - 4 : 0;
if (buckets.length === 0 || !stats) {
return <div className="qpd-chart-empty"> .</div>;
}
const skew = stats.rightTail > stats.leftTail ? 'positive' : stats.leftTail > stats.rightTail ? 'negative' : 'neutral';
return (
<div className="qpd-hist-wrap">
<svg className="qpd-hist-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
{[0.25, 0.5, 0.75, 1].map(r => {
const y = pad.t + innerH * (1 - r);
const cnt = Math.round(maxCount * r);
return (
<g key={r}>
<line x1={pad.l} y1={y} x2={W - pad.r} y2={y} stroke="rgba(255,255,255,0.06)" />
<text x={pad.l - 6} y={y + 3} textAnchor="end" className="qpd-chart-tick" fill="#64748b">{cnt}</text>
</g>
);
})}
{buckets.map((b, i) => {
const barH = (b.count / maxCount) * innerH;
const x = pad.l + i * (innerW / buckets.length) + 2;
const positive = b.from >= 0;
const fill = positive ? '#22c55e' : '#ef4444';
return (
<g key={b.bucket}>
<rect
x={x}
y={pad.t + innerH - barH}
width={Math.max(barW, 4)}
height={barH}
fill={fill}
opacity={0.85}
rx={2}
/>
{b.count > 0 && (
<text x={x + barW / 2} y={pad.t + innerH - barH - 4}
textAnchor="middle" fontSize="9" fill={fill} fontWeight="600">
{b.count}
</text>
)}
<text x={x + barW / 2} y={H - 8} textAnchor="middle"
className="qpd-chart-tick" fill="#64748b" fontSize="8">
{b.bucket.replace(' ', '')}
</text>
</g>
);
})}
<text x={(W + pad.l) / 2} y={H - 2} textAnchor="middle" className="qpd-chart-tick" fill="#94a3b8">
(%)
</text>
</svg>
<div className="qpd-hist-stats">
<div className="qpd-hist-stat">
<span> </span>
<strong className={stats.avg >= 0 ? 'up' : 'down'}>
{stats.avg >= 0 ? '+' : ''}{stats.avg.toFixed(2)}%
</strong>
</div>
<div className="qpd-hist-stat">
<span> / </span>
<strong>{stats.wins} / {stats.losses}</strong>
</div>
<div className="qpd-hist-stat">
<span> </span>
<strong className={skew === 'positive' ? 'up' : skew === 'negative' ? 'down' : ''}>
{skew === 'positive' ? '우측(양의) 꼬리 우세' : skew === 'negative' ? '좌측(손실) 꼬리 우세 — 주의' : '균형'}
</strong>
</div>
</div>
</div>
);
});
ReturnsHistogramChart.displayName = 'ReturnsHistogramChart';
export default ReturnsHistogramChart;
@@ -0,0 +1,91 @@
/**
* 백테스팅 · 투자분석 — 거래 시그널 테이블
*/
import React, { useState } from 'react';
import type { BacktestSignal } from '../../utils/backendApi';
import { repairUtf8Mojibake } from '../../utils/textEncoding';
import { SectionTitle } from '../shared/analysisDashboardUi';
const SIG_LABEL: Record<string, string> = {
BUY: '매수',
SELL: '매도',
SHORT_ENTRY: '공매도',
SHORT_EXIT: '공매도청산',
PARTIAL_SELL: '분할매도',
};
const SIG_COLOR: Record<string, string> = {
BUY: 'brd-sig-buy',
SELL: 'brd-sig-sell',
SHORT_ENTRY: 'brd-neg',
SHORT_EXIT: 'brd-pos',
PARTIAL_SELL: 'brd-warn',
};
const fmtDate = (ts: number) => {
const d = new Date(ts * 1000);
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')}`;
};
interface Props {
signals: BacktestSignal[];
/** true — 전체 표시, 접기 버튼 없음 */
expanded?: boolean;
className?: string;
}
export default function BacktestSignalTable({ signals, expanded = false, className }: Props) {
const [sigExpanded, setSigExpanded] = useState(expanded);
const showAll = expanded || sigExpanded;
const sigLimit = showAll ? signals.length : 8;
return (
<div className={`brd-card brd-card--full brd-card--sig-panel${className ? ` ${className}` : ''}`}>
<SectionTitle
icon="📋"
title="거래 시그널"
right={<span className="brd-section-count">{signals.length}</span>}
/>
{signals.length === 0 ? (
<div className="brd-empty-msg"> .</div>
) : (
<>
<div className="brd-sig-scroll">
<table className="brd-sig-table">
<thead>
<tr>
<th style={{ width: 36 }}>#</th>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}></th>
<th style={{ textAlign: 'center', width: 50 }}>#</th>
</tr>
</thead>
<tbody>
{signals.slice(0, sigLimit).map((s, i) => (
<tr key={i} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{i + 1}</td>
<td style={{ fontVariantNumeric: 'tabular-nums' }}>{fmtDate(s.time)}</td>
<td>
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
{repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{Math.round(s.price).toLocaleString()}
</td>
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{s.barIndex}</td>
</tr>
))}
</tbody>
</table>
</div>
{!expanded && signals.length > 8 && (
<button type="button" className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
</button>
)}
</>
)}
</div>
);
}
@@ -219,6 +219,9 @@ export default function BuilderPageShell({
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
'--bps-right-width': rightOpen ? `${rightWidth}px` : '0px',
'--bps-left-open': leftOpen ? '1' : '0',
'--bps-right-open': rightOpen ? '1' : '0',
'--bps-left-splitter': leftOpen ? '5px' : '0px',
'--bps-right-splitter': rightOpen ? '5px' : '0px',
'--bps-left-handle': '16px',
'--bps-splitter-w': '5px',
}) as React.CSSProperties, [leftOpen, leftWidth, rightOpen, rightWidth]);