분석 레포트 화면 수정

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: 거래 시그널 목록 ── */}
{!hideSignals && (
<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>
<BacktestSignalTable signals={signals} expanded={reportMode} />
</div>
{!reportMode && signals.length > 8 && (
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
</button>
)}
</>
)}
</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,25 +124,8 @@ 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>
);
}
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">
const headerActions = (
<>
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}></button>
<button
type="button"
@@ -171,14 +135,29 @@ export function AnalysisReportPage({ theme = 'dark' }: Props) {
>
/ PDF
</button>
</div>
</header>
</>
);
<div className="arp-body">
<aside
className="arp-sidebar btd-sidebar"
style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}
>
return (
<>
<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}
)}
center={<AnalysisReportCenterPanel model={reportModel} />}
right={<AnalysisReportSignalsPanel model={reportModel} />}
/>
<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>
<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]);
+917 -15
View File
@@ -1,9 +1,256 @@
/**
* The Quant Command Center — Analysis Report Styles
* TRADING_REPORT_UI_DESIGN.pdf 기반
*/
/* ── 페이지 레이아웃 ── */
/* ── BuilderPageShell 연동 (가상매매와 동일 3패널) ── */
.bps-page--arp .arp-context-bar {
display: flex;
flex-shrink: 0;
align-items: stretch;
min-height: 38px;
border-bottom: 1px solid var(--se-border, var(--border));
background: color-mix(in srgb, var(--se-header-bg, var(--bg2)) 88%, var(--se-bg, var(--bg1)));
}
.bps-page--arp .arp-context-bar__left-gap {
flex: 0 0 calc(
var(--bps-left-width, 380px)
+ var(--bps-left-handle, 16px)
+ var(--bps-left-splitter, 5px)
);
min-width: var(--bps-left-handle, 16px);
}
.bps-page--arp .arp-context-bar__right-gap {
flex: 0 0 calc(
var(--bps-right-width, 320px)
+ var(--bps-left-handle, 16px)
+ var(--bps-right-splitter, 5px)
);
min-width: var(--bps-left-handle, 16px);
}
.bps-page--arp .arp-context-bar__center {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px 14px;
padding: 8px 12px;
}
.bps-page--arp .arp-context-symbol {
font-size: 1.05rem;
font-weight: 800;
color: var(--se-gold, #e6c200);
letter-spacing: -0.02em;
line-height: 1.2;
}
.bps-page--arp .arp-context-meta {
font-size: 0.72rem;
font-weight: 500;
color: var(--se-text-dim, var(--text3));
line-height: 1.35;
}
.bps-page--arp .bps-center-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 10px 12px 12px;
background: var(--se-center-bg, var(--se-bg));
}
/* 좌·중·우 패널 동일 높이 — flex 체인 */
.bps-page--arp .bps-body {
align-items: stretch;
min-height: 0;
}
.bps-page--arp .bps-side-wrap {
align-self: stretch;
min-height: 0;
}
.bps-page--arp .bps-side-wrap--left .bps-left--collapsible {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: auto;
align-self: stretch;
}
.bps-page--arp .bps-main {
flex: 1;
min-height: 0;
align-self: stretch;
display: flex;
flex-direction: column;
}
.bps-page--arp .bps-main-row {
flex: 1;
min-height: 0;
align-items: stretch;
}
.bps-page--arp .bps-center,
.bps-page--arp .bps-center-work {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.bps-page--arp .bps-side-wrap--right .bps-right--collapsible {
align-self: stretch;
min-height: 0;
height: auto;
}
.bps-page--arp .bps-side-wrap--left {
align-self: stretch;
min-height: 0;
}
.bps-page--arp .bps-side-wrap--right {
align-self: stretch;
min-height: 0;
}
.bps-page--arp .bps-side-wrap--right .bps-right.bps-right--collapsible {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
align-self: stretch;
}
/* 좌측 실행 목록 카드 — 우측 시그널 카드와 동일 높이 */
.bps-page--arp .bps-side-wrap--left .bps-left--collapsible .bps-panel {
flex: 1;
min-height: 0;
height: auto;
margin: 10px;
padding: 14px 12px 12px;
border-radius: 14px;
background: var(--se-panel-card-bg);
border: 1px solid var(--se-panel-card-border);
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
}
.bps-page--arp .bps-panel-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.bps-page--arp .bps-panel-body .btd-exec-list {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.bps-page--arp .bps-panel-body .btd-exec-scroll,
.bps-page--arp .bps-panel-body .btd-exec-scroll.vl-scroll {
flex: 1;
min-height: 0;
}
.bps-page--arp .bps-right-body {
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.bps-page--arp .arp-center-inner {
width: 100%;
max-width: 960px;
margin: 0 auto;
}
/* 중앙 분석 카드 — 우측 시그널 카드와 동일 높이 */
.bps-page--arp .arp-center-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border-radius: 14px;
background: var(--se-panel-card-bg);
border: 1px solid var(--se-panel-card-border);
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
overflow: auto;
padding: 12px 14px 14px;
}
.bps-page--arp .arp-center-shell .arp-report-empty {
flex: 1;
min-height: 200px;
}
.bps-page--arp .arp-signals-panel {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 10px;
border-radius: 14px;
background: var(--se-panel-card-bg);
border: 1px solid var(--se-panel-card-border);
box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent);
}
.bps-page--arp .arp-signals-panel .brd-card--sig-fill {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
border: none;
border-radius: 0;
background: transparent;
}
.bps-page--arp .arp-signals-panel .brd-sig-scroll {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
}
.bps-page--arp .arp-signals-empty {
flex: 1;
min-height: 0;
margin: 10px;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
border-radius: 14px;
background: var(--se-panel-card-bg);
border: 1px solid var(--se-panel-card-border);
box-shadow: var(--se-panel-card-shadow);
text-align: center;
color: var(--se-text-dim, var(--text3));
font-size: 12px;
line-height: 1.5;
}
/* ── 페이지 레이아웃 (legacy) ── */
.arp-page {
display: flex;
flex-direction: column;
@@ -18,9 +265,122 @@
flex: 1;
min-height: 0;
overflow: hidden;
align-items: stretch;
gap: 0;
padding: 10px;
box-sizing: border-box;
}
/* ── 사이드바 ── */
/* ── 공통 카드 박스 (좌·우 동일 높이) ── */
.arp-card {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--se-bg-elevated, var(--bg2));
border: 1px solid var(--se-border, var(--border));
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
}
.arp-card--list {
flex-shrink: 0;
min-width: 180px;
max-width: 360px;
padding: 8px 6px 6px;
}
.arp-card--empty {
flex: 1;
min-width: 0;
}
.arp-main {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* ── 우측 2열: 분석 레포트 + 거래 시그널 ── */
.arp-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
gap: 10px;
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
.arp-card--report,
.arp-card--signals {
min-height: 0;
height: 100%;
}
.arp-card-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 14px 14px;
}
.arp-signals-inner {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
padding: 0;
}
.arp-signals-inner .brd-card--sig-fill {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
border: none;
border-radius: 0;
background: transparent;
}
.arp-signals-inner .brd-sig-scroll {
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
}
/* brd 테마 변수 — 분석·시그널 카드 공통 */
.arp-report-theme {
color: var(--se-text, var(--text));
font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif;
letter-spacing: normal;
word-break: keep-all;
line-height: 1.45;
--text: var(--se-text, #e8eef5);
--text2: var(--se-text-muted, #94a3b8);
--text3: var(--se-text-dim, #64748b);
--bg1: var(--se-bg, #0a1628);
--bg2: color-mix(in srgb, var(--se-bg-elevated, #0d1e35) 92%, #000);
--bg3: var(--se-bg-muted, #122038);
--bg4: color-mix(in srgb, var(--se-border, #1e3050) 55%, transparent);
--border: var(--se-border, #1e3050);
--brd-pos: var(--se-success, #22c55e);
--brd-neg: var(--se-danger, #ef4444);
--brd-warn: #eab308;
--brd-blue: #60a5fa;
--brd-purple: #a78bfa;
--brd-teal: #2dd4bf;
--brd-yellow: var(--se-gold, #e6c200);
--brd-green: #4ade80;
}
/* ── 사이드바 (legacy — 카드로 대체됨) ── */
.arp-sidebar {
display: flex;
flex-direction: column;
@@ -31,23 +391,23 @@
overflow: hidden;
}
.arp-splitter {
width: 4px;
width: 6px;
cursor: col-resize;
background: var(--se-border, #1e3050);
background: transparent;
flex-shrink: 0;
z-index: 10;
margin: 0 -3px;
position: relative;
}
.arp-splitter::after {
content: '';
position: absolute;
inset: 8px 2px;
border-radius: 3px;
background: var(--se-border, #1e3050);
transition: background 0.2s;
}
.arp-splitter:hover { background: var(--se-gold, #ffd700); }
/* ── 메인 영역 ── */
.arp-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
.arp-splitter:hover::after { background: var(--se-gold, #ffd700); }
/* ── 탭 바 ── */
.arp-type-tabs {
@@ -773,3 +1133,545 @@
.qcc-hero-row { gap: 4px; }
.qcc-hero { flex: 0 0 100%; }
}
/* ════════════════════════════════════════
Quant Performance Detail (qpd-*)
════════════════════════════════════════ */
.qpd-detail {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--se-bg, #0a1628);
}
.qpd-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--se-text-muted, #6a7f96);
padding: 32px;
}
.qpd-empty-icon { font-size: 48px; opacity: 0.4; }
.qpd-head {
flex-shrink: 0;
padding: 12px 16px 8px;
border-bottom: 1px solid var(--se-border, #1e3050);
}
.qpd-title {
margin: 0 0 6px;
font-size: 16px;
font-weight: 700;
color: var(--se-text-primary, #e8eef5);
}
.qpd-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qpd-badge {
font-size: 10.5px;
font-weight: 600;
padding: 3px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
color: var(--se-text-secondary, #94a3b8);
border: 1px solid var(--se-border, #1e3050);
}
.qpd-badge--muted { opacity: 0.85; }
.qpd-badge--ret.up { color: #22c55e; border-color: rgba(34, 197, 94, 0.35); }
.qpd-badge--ret.down { color: #ef4444; border-color: rgba(239, 68, 68, 0.35); }
.qpd-kpi-row {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--se-border, #1e3050);
background: var(--se-surface, #0d1e35);
}
@media (max-width: 1200px) {
.qpd-kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.qpd-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.qpd-kpi {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--se-border, #1e3050);
background: rgba(0, 0, 0, 0.15);
min-height: 108px;
display: flex;
flex-direction: column;
gap: 4px;
}
.qpd-kpi--good { border-color: rgba(34, 197, 94, 0.35); }
.qpd-kpi--warn { border-color: rgba(234, 179, 8, 0.35); }
.qpd-kpi--bad { border-color: rgba(239, 68, 68, 0.35); }
.qpd-kpi-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.qpd-kpi-label {
font-size: 10.5px;
font-weight: 600;
color: var(--se-text-muted, #6a7f96);
line-height: 1.3;
}
.qpd-kpi-badge {
font-size: 9px;
font-weight: 700;
padding: 2px 6px;
border-radius: 3px;
flex-shrink: 0;
}
.qpd-kpi-badge--good { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.qpd-kpi-badge--warn { background: rgba(234, 179, 8, 0.2); color: #eab308; }
.qpd-kpi-badge--bad { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.qpd-kpi-badge--neutral { background: rgba(148, 163, 184, 0.15); color: #94a3b8; }
.qpd-kpi-value {
font-size: 22px;
font-weight: 800;
color: var(--se-gold, #ffd700);
line-height: 1.1;
}
.qpd-kpi--good .qpd-kpi-value { color: #22c55e; }
.qpd-kpi--bad .qpd-kpi-value { color: #ef4444; }
.qpd-kpi-bench {
font-size: 9.5px;
color: var(--se-text-muted, #64748b);
}
.qpd-kpi-hint {
margin: 0;
font-size: 9px;
color: var(--se-text-muted, #556677);
line-height: 1.35;
flex: 1;
}
.qpd-subtabs {
flex-shrink: 0;
display: flex;
gap: 4px;
padding: 8px 12px 0;
border-bottom: 1px solid var(--se-border, #1e3050);
background: var(--se-surface, #0d1e35);
}
.qpd-subtab {
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
color: var(--se-text-muted, #6a7f96);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.qpd-subtab:hover { color: var(--se-gold, #ffd700); }
.qpd-subtab--on {
color: #ffd700;
border-bottom-color: #ffd700;
}
.qpd-panel {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
}
.qpd-overview {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 960px;
margin: 0 auto;
width: 100%;
}
.qpd-section {
border: 1px solid var(--se-border, #1e3050);
border-radius: 10px;
background: var(--se-surface, #0d1e35);
overflow: hidden;
}
.qpd-section--chart {
display: flex;
flex-direction: column;
min-height: 420px;
}
.qpd-section-head {
padding: 12px 14px 10px;
border-bottom: 1px solid var(--se-border, #1e3050);
}
.qpd-section-head h3 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 700;
color: var(--se-text-primary, #e8eef5);
}
.qpd-section-head p {
margin: 0;
font-size: 11px;
color: var(--se-text-muted, #6a7f96);
line-height: 1.45;
}
.qpd-equity-dd-wrap,
.qpd-hist-wrap {
padding: 8px 10px 12px;
}
.qpd-equity-dd-svg,
.qpd-hist-svg {
display: block;
width: 100%;
height: auto;
min-height: 200px;
}
.qpd-chart-axis-title { font-size: 9px; font-weight: 600; }
.qpd-chart-tick { font-size: 8.5px; }
.qpd-chart-annotation { font-size: 9px; font-weight: 700; }
.qpd-equity-dd-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 6px 4px 0;
font-size: 10.5px;
color: var(--se-text-muted, #6a7f96);
}
.qpd-leg::before {
content: '';
display: inline-block;
width: 10px;
height: 3px;
margin-right: 5px;
vertical-align: middle;
border-radius: 1px;
}
.qpd-leg--eq::before { background: #22c55e; }
.qpd-leg--dd::before { background: #ef4444; }
.qpd-leg--ret.up { color: #22c55e; }
.qpd-leg--ret.down { color: #ef4444; }
.qpd-hist-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 8px 4px 0;
border-top: 1px solid var(--se-border, #1e3050);
margin-top: 8px;
}
.qpd-hist-stat {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 10px;
color: var(--se-text-muted, #6a7f96);
}
.qpd-hist-stat strong {
font-size: 12px;
color: var(--se-text-primary, #e8eef5);
}
.qpd-hist-stat strong.up { color: #22c55e; }
.qpd-hist-stat strong.down { color: #ef4444; }
.qpd-chart-empty {
padding: 40px 20px;
text-align: center;
color: var(--se-text-muted, #6a7f96);
font-size: 13px;
}
.qpd-trade-chart {
flex: 1;
min-height: 380px;
display: flex;
flex-direction: column;
}
.qpd-trade-chart .btd-analysis-chart-wrap {
flex: 1;
min-height: 360px;
}
.qpd-log-wrap {
overflow: auto;
max-height: min(520px, 60vh);
}
.qpd-log-table {
width: 100%;
border-collapse: collapse;
font-size: 11.5px;
}
.qpd-log-table th {
position: sticky;
top: 0;
background: var(--se-surface-elevated, #122038);
padding: 8px 10px;
text-align: left;
font-size: 10px;
font-weight: 700;
color: var(--se-gold, #ffd700);
border-bottom: 1px solid var(--se-border, #1e3050);
white-space: nowrap;
}
.qpd-log-table td {
padding: 7px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: var(--se-text-secondary, #94a3b8);
}
.qpd-log-table tr:hover td { background: rgba(255, 215, 0, 0.04); }
.qpd-log-empty {
text-align: center;
padding: 24px;
color: var(--se-text-muted, #6a7f96);
}
.qpd-row--up td:nth-child(6) { color: #22c55e; }
.qpd-row--down td:nth-child(6) { color: #ef4444; }
.qpd-log-table .up { color: #22c55e; font-weight: 700; }
.qpd-log-table .down { color: #ef4444; font-weight: 700; }
/* ════════════════════════════════════════
분석레포트 우측 상세 (PDF 팝업과 동일 · 테마 색상)
════════════════════════════════════════ */
.arp-report-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--se-text-dim, var(--text3));
padding: 32px;
}
.arp-report-empty-icon { font-size: 48px; opacity: 0.45; }
.arp-report-print-header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 2px solid var(--se-gold, #e6c200);
}
.arp-report-print-title {
margin: 0 0 6px;
font-size: 1.05rem;
font-weight: 800;
color: var(--se-text, var(--text));
letter-spacing: -0.02em;
}
.arp-report-print-meta {
margin: 0;
font-size: 0.72rem;
color: var(--se-text-dim, var(--text3));
}
.arp-report-print-symbol {
margin: 8px 0 0;
font-size: 0.95rem;
font-weight: 700;
color: var(--se-gold, #e6c200);
}
.arp-report-print-footer {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--se-border, var(--border));
font-size: 0.62rem;
color: var(--se-text-dim, var(--text3));
text-align: center;
}
.arp-report-print-footer p { margin: 0; }
.arp-report-print-footer--signals {
flex-shrink: 0;
margin-top: auto;
padding: 8px 10px 10px;
}
/* BacktestDashboard — 레포트 레이아웃 (색상은 테마 유지) */
.bps-page--arp .arp-center-inner .brd-dashboard,
.bps-page--arp .arp-center-inner .brd-dashboard--report {
padding: 0;
background: transparent;
min-height: auto;
height: auto;
gap: 10px;
overflow: visible;
}
.bps-page--arp .arp-center-inner .brd-dash-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px 20px;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--se-gold) 8%, transparent) 0%,
color-mix(in srgb, var(--brd-blue) 10%, transparent) 100%
);
border: 1px solid color-mix(in srgb, var(--se-gold) 22%, var(--se-border));
}
.arp-report-theme .brd-dash-header-left { min-width: 0; }
.arp-report-theme .brd-dash-strategy {
font-size: 0.95rem;
word-break: keep-all;
overflow-wrap: anywhere;
line-height: 1.45;
white-space: normal;
}
.arp-report-theme .brd-dash-header-kpi {
display: flex;
flex-shrink: 0;
gap: 14px;
align-items: flex-start;
}
.arp-report-theme .brd-dash-header-kpi--report {
align-items: center;
}
.arp-report-theme .brd-dash-big-kpi {
text-align: right;
min-width: 4.8rem;
flex-shrink: 0;
}
.arp-report-theme .brd-dash-big-kpi:last-child { min-width: 7.5rem; }
.arp-report-theme .brd-dash-big-kpi--report {
display: inline-flex;
flex-direction: row;
align-items: baseline;
gap: 5px;
white-space: nowrap;
}
.arp-report-theme .brd-dash-big-kpi--report .brd-dash-big-label {
order: -1;
margin-top: 0;
font-size: 0.62rem;
color: var(--se-text-dim, var(--text3));
}
.arp-report-theme .brd-dash-big-val {
font-size: 1.05rem;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.15;
white-space: nowrap;
}
.arp-report-theme .brd-dash-big-label {
font-size: 0.62rem;
margin-top: 2px;
white-space: nowrap;
color: var(--se-text-dim, var(--text3));
}
.arp-report-theme .brd-dash-badge {
background: color-mix(in srgb, var(--brd-blue) 14%, transparent);
color: var(--brd-blue);
border-color: color-mix(in srgb, var(--brd-blue) 35%, transparent);
}
.arp-report-theme .brd-dash-badge--time,
.arp-report-theme .brd-dash-badge--method {
background: var(--bg3);
color: var(--text3);
border-color: var(--border);
}
.arp-report-theme .brd-kpi-card,
.arp-report-theme .brd-card {
background: var(--bg2);
border: 1px solid var(--border);
box-shadow: none;
overflow: visible;
}
.arp-report-theme .brd-kpi-card:hover {
transform: none;
box-shadow: none;
}
.arp-report-theme .brd-kpi-sub,
.arp-report-theme .brd-kpi-label,
.arp-report-theme .brd-metric-label,
.arp-report-theme .brd-metric-note,
.arp-report-theme .brd-section-text,
.arp-report-theme .brd-compare-label,
.arp-report-theme .brd-pnl-label,
.arp-report-theme .brd-bench-label {
overflow: visible;
text-overflow: unset;
white-space: normal;
word-break: keep-all;
line-height: 1.4;
}
.arp-report-theme .brd-kpi-sub { min-height: 1.2em; }
.arp-report-theme .brd-section-title {
background: var(--bg3);
border-bottom-color: var(--border);
color: var(--text2);
}
.arp-report-theme .brd-metric-label,
.arp-report-theme .brd-kpi-label {
color: var(--text3);
}
.arp-report-theme .brd-compare-track,
.arp-report-theme .brd-pnl-track,
.arp-report-theme .brd-bench-summary {
background: var(--bg3);
}
.arp-report-theme .brd-sig-table th {
background: var(--bg3);
color: var(--text2);
}
.arp-report-theme .brd-sig-table td {
border-color: color-mix(in srgb, var(--border) 70%, transparent);
}
.arp-report-theme .brd-sig-even {
background: color-mix(in srgb, var(--bg3) 50%, transparent);
}
@media (max-width: 960px) {
.arp-detail-grid {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) minmax(200px, 34vh);
}
}
@media (max-width: 720px) {
.arp-body { padding: 6px; gap: 0; }
.arp-card--list { padding: 6px 4px 4px; }
.bps-page--arp .arp-center-inner .brd-dash-header {
grid-template-columns: 1fr;
}
.bps-page--arp .arp-center-inner .brd-dash-header-kpi {
justify-content: flex-start;
}
}